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
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
import kr.itn.itnhub.feed.InvalidMessageException; |
| 7 | 7 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 8 | 8 |
import kr.itn.itnhub.seed.SeedParseException; |
| 9 |
+import kr.itn.itnhub.stage.InvalidStageException; |
|
| 9 | 10 |
import org.springframework.http.HttpStatus; |
| 10 | 11 |
import org.springframework.http.ResponseEntity; |
| 11 | 12 |
import org.springframework.web.bind.MethodArgumentNotValidException; |
... | ... | @@ -47,6 +48,9 @@ |
| 47 | 48 |
* |
| 48 | 49 |
* <p>{@link InvalidContactException}은 담당자 구분(category)이 APPLICANT/MJ/LAWYER/OPERATOR
|
| 49 | 50 |
* 중 하나가 아니거나, 기관에 배정하려는 담당자의 구분이 그 역할과 맞지 않을 때 |
| 51 |
+ * 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p> |
|
| 52 |
+ * |
|
| 53 |
+ * <p>{@link InvalidStageException}은 진행단계 변경 요청의 값이 1..12 범위를 벗어났을 때
|
|
| 50 | 54 |
* 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p> |
| 51 | 55 |
* |
| 52 | 56 |
* <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
|
... | ... | @@ -102,4 +106,9 @@ |
| 102 | 106 |
public ResponseEntity<ApiError> handleInvalidContact(InvalidContactException e) {
|
| 103 | 107 |
return ResponseEntity.badRequest().body(new ApiError(e.getMessage())); |
| 104 | 108 |
} |
| 109 |
+ |
|
| 110 |
+ @ExceptionHandler(InvalidStageException.class) |
|
| 111 |
+ public ResponseEntity<ApiError> handleInvalidStage(InvalidStageException e) {
|
|
| 112 |
+ return ResponseEntity.badRequest().body(new ApiError(e.getMessage())); |
|
| 113 |
+ } |
|
| 105 | 114 |
} |
--- src/main/java/kr/itn/itnhub/org/OrgResponse.java
+++ src/main/java/kr/itn/itnhub/org/OrgResponse.java
... | ... | @@ -13,7 +13,8 @@ |
| 13 | 13 |
Contact lawyer, |
| 14 | 14 |
String lawyerAssignedDate, |
| 15 | 15 |
String channelIdMj, |
| 16 |
- String channelIdLaw) {
|
|
| 16 |
+ String channelIdLaw, |
|
| 17 |
+ Integer stage) {
|
|
| 17 | 18 |
|
| 18 | 19 |
public static OrgResponse of(Organization org) {
|
| 19 | 20 |
return new OrgResponse( |
... | ... | @@ -27,6 +28,7 @@ |
| 27 | 28 |
org.getLawyer(), |
| 28 | 29 |
org.getLawyerAssignedDate(), |
| 29 | 30 |
org.getChannelIdMj(), |
| 30 |
- org.getChannelIdLaw()); |
|
| 31 |
+ org.getChannelIdLaw(), |
|
| 32 |
+ org.getStage()); |
|
| 31 | 33 |
} |
| 32 | 34 |
} |
--- src/main/java/kr/itn/itnhub/org/Organization.java
+++ src/main/java/kr/itn/itnhub/org/Organization.java
... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 |
private String lawyerAssignedDate; |
| 16 | 16 |
private String channelIdMj; |
| 17 | 17 |
private String channelIdLaw; |
| 18 |
+ private Integer stage; |
|
| 18 | 19 |
|
| 19 | 20 |
/** findAll/findById가 contact 테이블과 조인해 채워준다. 시드/upsert 경로에서는 비어 있다. */ |
| 20 | 21 |
private Contact applicant; |
... | ... | @@ -81,6 +82,10 @@ |
| 81 | 82 |
public String getChannelIdLaw() { return channelIdLaw; }
|
| 82 | 83 |
public void setChannelIdLaw(String channelIdLaw) { this.channelIdLaw = channelIdLaw; }
|
| 83 | 84 |
|
| 85 |
+ /** null이면 아직 어떤 진행단계도 시작되지 않은 것이다(채널 생성 전). */ |
|
| 86 |
+ public Integer getStage() { return stage; }
|
|
| 87 |
+ public void setStage(Integer stage) { this.stage = stage; }
|
|
| 88 |
+ |
|
| 84 | 89 |
public Contact getApplicant() { return applicant; }
|
| 85 | 90 |
public void setApplicant(Contact applicant) { this.applicant = applicant; }
|
| 86 | 91 |
|
--- src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
... | ... | @@ -33,4 +33,7 @@ |
| 33 | 33 |
|
| 34 | 34 |
int updateChannelIdLaw(@Param("id") Long id,
|
| 35 | 35 |
@Param("channelId") String channelId);
|
| 36 |
+ |
|
| 37 |
+ int updateStage(@Param("id") Long id,
|
|
| 38 |
+ @Param("stage") Integer stage);
|
|
| 36 | 39 |
} |
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 6 | 6 |
import kr.itn.itnhub.org.Organization; |
| 7 | 7 |
import kr.itn.itnhub.org.OrganizationMapper; |
| 8 |
+import kr.itn.itnhub.stage.StageHistoryMapper; |
|
| 8 | 9 |
import org.slf4j.Logger; |
| 9 | 10 |
import org.slf4j.LoggerFactory; |
| 10 | 11 |
import org.springframework.stereotype.Service; |
... | ... | @@ -32,13 +33,16 @@ |
| 32 | 33 |
private final MattermostClient mattermost; |
| 33 | 34 |
private final OrganizationMapper mapper; |
| 34 | 35 |
private final MattermostProperties props; |
| 36 |
+ private final StageHistoryMapper stageHistoryMapper; |
|
| 35 | 37 |
|
| 36 | 38 |
public ChannelProvisionService(MattermostClient mattermost, |
| 37 | 39 |
OrganizationMapper mapper, |
| 38 |
- MattermostProperties props) {
|
|
| 40 |
+ MattermostProperties props, |
|
| 41 |
+ StageHistoryMapper stageHistoryMapper) {
|
|
| 39 | 42 |
this.mattermost = mattermost; |
| 40 | 43 |
this.mapper = mapper; |
| 41 | 44 |
this.props = props; |
| 45 |
+ this.stageHistoryMapper = stageHistoryMapper; |
|
| 42 | 46 |
} |
| 43 | 47 |
|
| 44 | 48 |
public ProvisionResult provision(Long orgId) {
|
... | ... | @@ -62,9 +66,39 @@ |
| 62 | 66 |
ProvisionOutcome law = ensure(org, ChannelKind.LAW, |
| 63 | 67 |
org.getChannelIdLaw(), props.channelNameLaw(), messages); |
| 64 | 68 |
|
| 69 |
+ maybeStartStage(org.getId()); |
|
| 70 |
+ |
|
| 65 | 71 |
return new ProvisionResult(mj, law, messages.toString().trim()); |
| 66 | 72 |
} |
| 67 | 73 |
|
| 74 |
+ /** |
|
| 75 |
+ * 채널 2개가 이번 호출로 처음 모두 갖춰지고 아직 어떤 단계도 시작되지 않았다면 |
|
| 76 |
+ * 진행단계를 1(신청)로 자동 시작한다. 이미 단계가 있는 기관은 건드리지 않는다 |
|
| 77 |
+ * (재실행 안전성 - 사람이 수동으로 바꿔둔 단계를 채널 재확인이 되돌리면 안 된다). |
|
| 78 |
+ * |
|
| 79 |
+ * <p>이 훅은 채널 생성이 이미 끝난 뒤에 실행되므로, 실패해도 provision() 결과에는 |
|
| 80 |
+ * 영향을 주지 않고 경고 로그만 남긴다 - stage_history 문제로 채널 생성 자체를 |
|
| 81 |
+ * 실패로 보고하면 재시도만 반복하게 만든다.</p> |
|
| 82 |
+ */ |
|
| 83 |
+ private void maybeStartStage(Long orgId) {
|
|
| 84 |
+ try {
|
|
| 85 |
+ Organization current = mapper.findById(orgId); |
|
| 86 |
+ if (current == null || current.getStage() != null) {
|
|
| 87 |
+ return; |
|
| 88 |
+ } |
|
| 89 |
+ if (filled(current.getChannelIdMj()) && filled(current.getChannelIdLaw())) {
|
|
| 90 |
+ mapper.updateStage(orgId, 1); |
|
| 91 |
+ stageHistoryMapper.insert(orgId, 1); |
|
| 92 |
+ } |
|
| 93 |
+ } catch (RuntimeException e) {
|
|
| 94 |
+ log.warn("진행단계 자동 시작 실패 org={}", orgId, e);
|
|
| 95 |
+ } |
|
| 96 |
+ } |
|
| 97 |
+ |
|
| 98 |
+ private static boolean filled(String value) {
|
|
| 99 |
+ return value != null && !value.isBlank(); |
|
| 100 |
+ } |
|
| 101 |
+ |
|
| 68 | 102 |
private ProvisionOutcome ensure(Organization org, |
| 69 | 103 |
ChannelKind kind, |
| 70 | 104 |
String storedChannelId, |
+++ src/main/java/kr/itn/itnhub/stage/InvalidStageException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 진행단계 값이 1..12 범위를 벗어났을 때(또는 비어 있을 때) 던진다. 흔한 사용자 실수이므로 | |
| 5 | + * {@link kr.itn.itnhub.config.GlobalExceptionHandler}가 400으로 변환한다. | |
| 6 | + */ | |
| 7 | +public class InvalidStageException extends RuntimeException { | |
| 8 | + public InvalidStageException(String message) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/stage/StageController.java
... | ... | @@ -0,0 +1,23 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgResponse; | |
| 4 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 5 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 6 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 7 | +import org.springframework.web.bind.annotation.RestController; | |
| 8 | + | |
| 9 | +/** 기관관리 상세의 [진행단계 변경] 및 업무단계 현황 스트립이 부르는 엔드포인트. */ | |
| 10 | +@RestController | |
| 11 | +public class StageController { | |
| 12 | + | |
| 13 | + private final StageService stageService; | |
| 14 | + | |
| 15 | + public StageController(StageService stageService) { | |
| 16 | + this.stageService = stageService; | |
| 17 | + } | |
| 18 | + | |
| 19 | + @PutMapping("/api/orgs/{id}/stage") | |
| 20 | + public OrgResponse updateStage(@PathVariable Long id, @RequestBody StageRequest request) { | |
| 21 | + return stageService.updateStage(id, request); | |
| 22 | + } | |
| 23 | +} |
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
... | ... | @@ -0,0 +1,15 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface StageHistoryMapper { | |
| 10 | + | |
| 11 | + /** 최신순. */ | |
| 12 | + List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId); | |
| 13 | + | |
| 14 | + int insert(@Param("orgId") Long orgId, @Param("stage") int stage); | |
| 15 | +} |
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
... | ... | @@ -0,0 +1,14 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +/** stage_history 한 행. MyBatis 매핑용이라 record가 아니라 setter가 있는 클래스로 둔다. */ | |
| 4 | +public class StageHistoryRow { | |
| 5 | + | |
| 6 | + private int stage; | |
| 7 | + private long changedAt; | |
| 8 | + | |
| 9 | + public int getStage() { return stage; } | |
| 10 | + public void setStage(int stage) { this.stage = stage; } | |
| 11 | + | |
| 12 | + public long getChangedAt() { return changedAt; } | |
| 13 | + public void setChangedAt(long changedAt) { this.changedAt = changedAt; } | |
| 14 | +} |
+++ src/main/java/kr/itn/itnhub/stage/StageRequest.java
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +/** 진행단계 변경 요청. 1..12 검증은 {@link StageService}가 한다(범위를 벗어난 값에 대해 | |
| 4 | + * 한글 메시지를 직접 지어야 하므로 {@code @Valid}가 아니라 수동 검증을 쓴다). */ | |
| 5 | +public record StageRequest(Integer stage) { | |
| 6 | +} |
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
... | ... | @@ -0,0 +1,43 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrgResponse; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.springframework.stereotype.Service; | |
| 8 | +import org.springframework.transaction.annotation.Transactional; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 기관관리 상세 화면의 [진행단계 변경] 및 업무단계 현황 스트립이 쓰는 서비스. 단계 값 자체는 | |
| 12 | + * organization.stage 한 칸에 저장하고, 변경 이력은 전부 stage_history에 별도로 쌓는다 - | |
| 13 | + * 타임라인 탭이 "언제 몇 단계로 넘어갔는지"를 나중에 다시 훑어볼 수 있어야 하기 때문이다. | |
| 14 | + */ | |
| 15 | +@Service | |
| 16 | +public class StageService { | |
| 17 | + | |
| 18 | + private final OrganizationMapper orgMapper; | |
| 19 | + private final StageHistoryMapper historyMapper; | |
| 20 | + | |
| 21 | + public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper) { | |
| 22 | + this.orgMapper = orgMapper; | |
| 23 | + this.historyMapper = historyMapper; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Transactional | |
| 27 | + public OrgResponse updateStage(Long orgId, StageRequest request) { | |
| 28 | + Organization org = orgMapper.findById(orgId); | |
| 29 | + if (org == null) { | |
| 30 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 31 | + } | |
| 32 | + | |
| 33 | + Integer stage = request.stage(); | |
| 34 | + if (stage == null || stage < 1 || stage > 12) { | |
| 35 | + throw new InvalidStageException("진행단계는 1에서 12 사이의 값이어야 합니다."); | |
| 36 | + } | |
| 37 | + | |
| 38 | + orgMapper.updateStage(orgId, stage); | |
| 39 | + historyMapper.insert(orgId, stage); | |
| 40 | + | |
| 41 | + return OrgResponse.of(orgMapper.findById(orgId)); | |
| 42 | + } | |
| 43 | +} |
+++ src/main/java/kr/itn/itnhub/timeline/TimelineController.java
... | ... | @@ -0,0 +1,23 @@ |
| 1 | +package kr.itn.itnhub.timeline; | |
| 2 | + | |
| 3 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 4 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 5 | +import org.springframework.web.bind.annotation.RestController; | |
| 6 | + | |
| 7 | +import java.util.List; | |
| 8 | + | |
| 9 | +/** 기관관리 상세의 타임라인 탭이 부르는 읽기 전용 엔드포인트. */ | |
| 10 | +@RestController | |
| 11 | +public class TimelineController { | |
| 12 | + | |
| 13 | + private final TimelineService timelineService; | |
| 14 | + | |
| 15 | + public TimelineController(TimelineService timelineService) { | |
| 16 | + this.timelineService = timelineService; | |
| 17 | + } | |
| 18 | + | |
| 19 | + @GetMapping("/api/orgs/{id}/timeline") | |
| 20 | + public List<TimelineEvent> timeline(@PathVariable Long id) { | |
| 21 | + return timelineService.timeline(id); | |
| 22 | + } | |
| 23 | +} |
+++ src/main/java/kr/itn/itnhub/timeline/TimelineEvent.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.timeline; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 타임라인 탭 한 줄. {@code type}은 {@code STAGE}/{@code MEMO}/{@code FILE} 중 하나다. | |
| 5 | + * | |
| 6 | + * <p>{@code STAGE}: title = 단계 번호(문자열, 프론트가 라벨로 매핑), detail = null.<br> | |
| 7 | + * {@code MEMO}: title = 담당자 이름, detail = 메모 첫 줄(60자 절단).<br> | |
| 8 | + * {@code FILE}: title = 파일명, detail = 채널 라벨(문정원/법률검토) + " · " + 올린 사람.</p> | |
| 9 | + */ | |
| 10 | +public record TimelineEvent(String type, long at, String title, String detail) { | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/timeline/TimelineService.java
... | ... | @@ -0,0 +1,103 @@ |
| 1 | +package kr.itn.itnhub.timeline; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.feed.FileView; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.memo.WorkMemo; | |
| 6 | +import kr.itn.itnhub.memo.WorkMemoMapper; | |
| 7 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 8 | +import kr.itn.itnhub.org.Organization; | |
| 9 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 10 | +import kr.itn.itnhub.stage.StageHistoryMapper; | |
| 11 | +import kr.itn.itnhub.stage.StageHistoryRow; | |
| 12 | +import org.slf4j.Logger; | |
| 13 | +import org.slf4j.LoggerFactory; | |
| 14 | +import org.springframework.stereotype.Service; | |
| 15 | + | |
| 16 | +import java.util.ArrayList; | |
| 17 | +import java.util.Comparator; | |
| 18 | +import java.util.List; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * 기관관리 상세의 타임라인 탭이 쓰는 서비스. 단계변경 이력(stage_history) + 업무메모(work_memo) + | |
| 22 | + * 채널 첨부파일(Mattermost)을 한 번에 모아 최신순으로 합쳐 돌려준다. | |
| 23 | + * | |
| 24 | + * <p>Mattermost 조회는 채널별로 개별 catch한다 - 한쪽 채널(혹은 Mattermost 서버 자체)이 | |
| 25 | + * 실패해도 이미 DB에서 구한 단계·메모 이력까지 통째로 못 보여줄 이유가 없다.</p> | |
| 26 | + */ | |
| 27 | +@Service | |
| 28 | +public class TimelineService { | |
| 29 | + | |
| 30 | + private static final Logger log = LoggerFactory.getLogger(TimelineService.class); | |
| 31 | + | |
| 32 | + private static final String LABEL_MJ = "문정원"; | |
| 33 | + private static final String LABEL_LAW = "법률검토"; | |
| 34 | + | |
| 35 | + /** 메모 상세문(detail)로 보여줄 첫 줄의 최대 길이. */ | |
| 36 | + private static final int MEMO_DETAIL_MAX_LEN = 60; | |
| 37 | + | |
| 38 | + private final OrganizationMapper orgMapper; | |
| 39 | + private final StageHistoryMapper stageHistoryMapper; | |
| 40 | + private final WorkMemoMapper memoMapper; | |
| 41 | + private final MattermostClient mattermost; | |
| 42 | + | |
| 43 | + public TimelineService(OrganizationMapper orgMapper, StageHistoryMapper stageHistoryMapper, | |
| 44 | + WorkMemoMapper memoMapper, MattermostClient mattermost) { | |
| 45 | + this.orgMapper = orgMapper; | |
| 46 | + this.stageHistoryMapper = stageHistoryMapper; | |
| 47 | + this.memoMapper = memoMapper; | |
| 48 | + this.mattermost = mattermost; | |
| 49 | + } | |
| 50 | + | |
| 51 | + public List<TimelineEvent> timeline(Long orgId) { | |
| 52 | + Organization org = orgMapper.findById(orgId); | |
| 53 | + if (org == null) { | |
| 54 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 55 | + } | |
| 56 | + | |
| 57 | + List<TimelineEvent> events = new ArrayList<>(); | |
| 58 | + | |
| 59 | + for (StageHistoryRow row : stageHistoryMapper.findByOrg(orgId)) { | |
| 60 | + events.add(new TimelineEvent("STAGE", row.getChangedAt(), String.valueOf(row.getStage()), null)); | |
| 61 | + } | |
| 62 | + | |
| 63 | + for (WorkMemo memo : memoMapper.findByOrg(orgId)) { | |
| 64 | + events.add(new TimelineEvent("MEMO", memo.getCreatedAt(), memo.getContactName(), | |
| 65 | + firstLine(memo.getBody()))); | |
| 66 | + } | |
| 67 | + | |
| 68 | + events.addAll(fileEvents(org.getChannelIdMj(), LABEL_MJ)); | |
| 69 | + events.addAll(fileEvents(org.getChannelIdLaw(), LABEL_LAW)); | |
| 70 | + | |
| 71 | + events.sort(Comparator.comparingLong(TimelineEvent::at).reversed()); | |
| 72 | + return events; | |
| 73 | + } | |
| 74 | + | |
| 75 | + private List<TimelineEvent> fileEvents(String channelId, String channelLabel) { | |
| 76 | + if (channelId == null || channelId.isBlank()) { | |
| 77 | + return List.of(); | |
| 78 | + } | |
| 79 | + try { | |
| 80 | + List<FileView> files = mattermost.collectChannelFiles(channelId); | |
| 81 | + List<TimelineEvent> events = new ArrayList<>(files.size()); | |
| 82 | + for (FileView file : files) { | |
| 83 | + events.add(new TimelineEvent("FILE", file.createAt(), file.name(), | |
| 84 | + channelLabel + " · " + file.uploader())); | |
| 85 | + } | |
| 86 | + return events; | |
| 87 | + } catch (RuntimeException e) { | |
| 88 | + log.warn("타임라인 자료 조회 실패 channel={}", channelLabel, e); | |
| 89 | + return List.of(); | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + private String firstLine(String body) { | |
| 94 | + if (body == null) { | |
| 95 | + return ""; | |
| 96 | + } | |
| 97 | + String line = body.strip().split("\\R", 2)[0]; | |
| 98 | + if (line.length() > MEMO_DETAIL_MAX_LEN) { | |
| 99 | + return line.substring(0, MEMO_DETAIL_MAX_LEN); | |
| 100 | + } | |
| 101 | + return line; | |
| 102 | + } | |
| 103 | +} |
+++ src/main/resources/db/migration/V8__org_stage.sql
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +alter table organization add column stage smallint; | |
| 2 | + | |
| 3 | +create table stage_history ( | |
| 4 | + id bigserial primary key, | |
| 5 | + org_id bigint not null references organization (id) on delete cascade, | |
| 6 | + stage smallint not null, | |
| 7 | + changed_at timestamptz not null default now() | |
| 8 | +); | |
| 9 | +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
... | ... | @@ -20,6 +20,7 @@ |
| 20 | 20 |
<result property="lawyerAssignedDate" column="lawyer_assigned_date"/> |
| 21 | 21 |
<result property="channelIdMj" column="channel_id_mj"/> |
| 22 | 22 |
<result property="channelIdLaw" column="channel_id_law"/> |
| 23 |
+ <result property="stage" column="stage"/> |
|
| 23 | 24 |
|
| 24 | 25 |
<association property="applicant" javaType="kr.itn.itnhub.contact.Contact"> |
| 25 | 26 |
<id property="id" column="applicant_id"/> |
... | ... | @@ -69,7 +70,7 @@ |
| 69 | 70 |
<sql id="joinedColumns"> |
| 70 | 71 |
o.id, o.org_no, o.org_name, o.channel_slug, |
| 71 | 72 |
o.applicant_contact_id, o.mj_contact_id, o.itn_contact_id, o.lawyer_contact_id, |
| 72 |
- o.lawyer_assigned_date, o.channel_id_mj, o.channel_id_law, |
|
| 73 |
+ o.lawyer_assigned_date, o.channel_id_mj, o.channel_id_law, o.stage, |
|
| 73 | 74 |
ac.id as applicant_id, ac.category as applicant_category, ac.name as applicant_name, |
| 74 | 75 |
ac.affiliation as applicant_affiliation, ac.dept_name as applicant_dept_name, |
| 75 | 76 |
ac.title as applicant_title, ac.phone as applicant_phone, ac.email as applicant_email, |
... | ... | @@ -155,4 +156,11 @@ |
| 155 | 156 |
where id = #{id}
|
| 156 | 157 |
</update> |
| 157 | 158 |
|
| 159 |
+ <update id="updateStage"> |
|
| 160 |
+ update organization set |
|
| 161 |
+ stage = #{stage},
|
|
| 162 |
+ updated_at = now() |
|
| 163 |
+ where id = #{id}
|
|
| 164 |
+ </update> |
|
| 165 |
+ |
|
| 158 | 166 |
</mapper> |
+++ src/main/resources/mapper/StageHistoryMapper.xml
... | ... | @@ -0,0 +1,21 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.stage.StageHistoryMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + changed_at을 밀리초 epoch로 내려준다 - WorkMemoMapper.created_at과 동일한 관례. | |
| 8 | + --> | |
| 9 | + <select id="findByOrg" resultType="kr.itn.itnhub.stage.StageHistoryRow"> | |
| 10 | + select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at | |
| 11 | + from stage_history | |
| 12 | + where org_id = #{orgId} | |
| 13 | + order by changed_at desc, id desc | |
| 14 | + </select> | |
| 15 | + | |
| 16 | + <insert id="insert"> | |
| 17 | + insert into stage_history (org_id, stage) | |
| 18 | + values (#{orgId}, #{stage}) | |
| 19 | + </insert> | |
| 20 | + | |
| 21 | +</mapper> |
--- src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
... | ... | @@ -295,4 +295,56 @@ |
| 295 | 295 |
assertThat(after.getChannelIdMj()).isNull(); |
| 296 | 296 |
assertThat(after.getChannelIdLaw()).isEqualTo("id-law");
|
| 297 | 297 |
} |
| 298 |
+ |
|
| 299 |
+ @Test |
|
| 300 |
+ void 채널_2개가_처음_모두_갖춰지면_1단계로_자동_시작하고_이력이_한_행_남는다() {
|
|
| 301 |
+ when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); |
|
| 302 |
+ when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); |
|
| 303 |
+ when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("id-mj");
|
|
| 304 |
+ when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("id-law");
|
|
| 305 |
+ |
|
| 306 |
+ service.provision(orgId); |
|
| 307 |
+ |
|
| 308 |
+ Organization after = mapper.findById(orgId); |
|
| 309 |
+ assertThat(after.getStage()).isEqualTo(1); |
|
| 310 |
+ assertThat(jdbc.queryForObject( |
|
| 311 |
+ "select count(*) from stage_history where org_id = ?", Integer.class, orgId)) |
|
| 312 |
+ .isEqualTo(1); |
|
| 313 |
+ } |
|
| 314 |
+ |
|
| 315 |
+ @Test |
|
| 316 |
+ void 이미_단계가_있는_기관은_채널_재확인으로_단계가_바뀌지_않는다() {
|
|
| 317 |
+ mapper.updateChannelIdMj(orgId, "id-mj"); |
|
| 318 |
+ mapper.updateChannelIdLaw(orgId, "id-law"); |
|
| 319 |
+ mapper.updateStage(orgId, 5); |
|
| 320 |
+ |
|
| 321 |
+ service.provision(orgId); |
|
| 322 |
+ |
|
| 323 |
+ Organization after = mapper.findById(orgId); |
|
| 324 |
+ assertThat(after.getStage()).isEqualTo(5); |
|
| 325 |
+ assertThat(jdbc.queryForObject( |
|
| 326 |
+ "select count(*) from stage_history where org_id = ?", Integer.class, orgId)) |
|
| 327 |
+ .isEqualTo(0); |
|
| 328 |
+ } |
|
| 329 |
+ |
|
| 330 |
+ @Test |
|
| 331 |
+ void 이미_채널이_있어_CACHED만_반환되는_재실행은_이력을_추가하지_않는다() {
|
|
| 332 |
+ mapper.updateChannelIdMj(orgId, "id-mj"); |
|
| 333 |
+ mapper.updateChannelIdLaw(orgId, "id-law"); |
|
| 334 |
+ |
|
| 335 |
+ // 첫 provision으로 자동 1단계 시작 + 이력 1건. |
|
| 336 |
+ service.provision(orgId); |
|
| 337 |
+ assertThat(jdbc.queryForObject( |
|
| 338 |
+ "select count(*) from stage_history where org_id = ?", Integer.class, orgId)) |
|
| 339 |
+ .isEqualTo(1); |
|
| 340 |
+ |
|
| 341 |
+ // 다시 호출해도(CACHED만 반환) 이력은 늘지 않는다. |
|
| 342 |
+ ProvisionResult result = service.provision(orgId); |
|
| 343 |
+ |
|
| 344 |
+ assertThat(result.mj()).isEqualTo(ProvisionOutcome.CACHED); |
|
| 345 |
+ assertThat(result.law()).isEqualTo(ProvisionOutcome.CACHED); |
|
| 346 |
+ assertThat(jdbc.queryForObject( |
|
| 347 |
+ "select count(*) from stage_history where org_id = ?", Integer.class, orgId)) |
|
| 348 |
+ .isEqualTo(1); |
|
| 349 |
+ } |
|
| 298 | 350 |
} |
+++ src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
... | ... | @@ -0,0 +1,125 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 11 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 12 | +import org.springframework.http.MediaType; | |
| 13 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 14 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 15 | +import org.springframework.test.web.servlet.MockMvc; | |
| 16 | + | |
| 17 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 18 | +import static org.hamcrest.Matchers.containsString; | |
| 19 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 20 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 21 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 22 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 23 | + | |
| 24 | +@AutoConfigureMockMvc | |
| 25 | +@WithMockUser(roles = "ADMIN") | |
| 26 | +class StageControllerTest extends AbstractDbTest { | |
| 27 | + | |
| 28 | + @Autowired | |
| 29 | + MockMvc mvc; | |
| 30 | + | |
| 31 | + @Autowired | |
| 32 | + OrganizationMapper mapper; | |
| 33 | + | |
| 34 | + @Autowired | |
| 35 | + JdbcTemplate jdbc; | |
| 36 | + | |
| 37 | + @MockBean | |
| 38 | + MattermostClient mattermost; | |
| 39 | + | |
| 40 | + private Long orgId; | |
| 41 | + | |
| 42 | + @BeforeEach | |
| 43 | + void setUp() { | |
| 44 | + jdbc.update("delete from organization"); | |
| 45 | + | |
| 46 | + Organization org = new Organization(); | |
| 47 | + org.setOrgNo("001"); | |
| 48 | + org.setOrgName("국제방송교류재단"); | |
| 49 | + org.setChannelSlug("001"); | |
| 50 | + mapper.upsertBySeed(org); | |
| 51 | + | |
| 52 | + orgId = mapper.findAll().get(0).getId(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void 단계를_바꾸면_응답과_재조회에_반영되고_이력이_남는다() throws Exception { | |
| 57 | + mvc.perform(put("/api/orgs/{id}/stage", orgId) | |
| 58 | + .with(csrf()) | |
| 59 | + .contentType(MediaType.APPLICATION_JSON) | |
| 60 | + .content("{\"stage\": 3}")) | |
| 61 | + .andExpect(status().isOk()) | |
| 62 | + .andExpect(jsonPath("$.stage").value(3)); | |
| 63 | + | |
| 64 | + assertThat(mapper.findById(orgId).getStage()).isEqualTo(3); | |
| 65 | + assertThat(jdbc.queryForObject( | |
| 66 | + "select count(*) from stage_history where org_id = ? and stage = 3", | |
| 67 | + Integer.class, orgId)).isEqualTo(1); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void 여러_번_바꾸면_이력이_각각_쌓인다() throws Exception { | |
| 72 | + mvc.perform(put("/api/orgs/{id}/stage", orgId).with(csrf()) | |
| 73 | + .contentType(MediaType.APPLICATION_JSON).content("{\"stage\": 1}")) | |
| 74 | + .andExpect(status().isOk()); | |
| 75 | + mvc.perform(put("/api/orgs/{id}/stage", orgId).with(csrf()) | |
| 76 | + .contentType(MediaType.APPLICATION_JSON).content("{\"stage\": 2}")) | |
| 77 | + .andExpect(status().isOk()); | |
| 78 | + | |
| 79 | + assertThat(jdbc.queryForObject( | |
| 80 | + "select count(*) from stage_history where org_id = ?", Integer.class, orgId)) | |
| 81 | + .isEqualTo(2); | |
| 82 | + assertThat(mapper.findById(orgId).getStage()).isEqualTo(2); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + void 범위를_벗어난_값은_400이다() throws Exception { | |
| 87 | + mvc.perform(put("/api/orgs/{id}/stage", orgId) | |
| 88 | + .with(csrf()) | |
| 89 | + .contentType(MediaType.APPLICATION_JSON) | |
| 90 | + .content("{\"stage\": 13}")) | |
| 91 | + .andExpect(status().isBadRequest()) | |
| 92 | + .andExpect(jsonPath("$.message").value(containsString("1에서 12"))); | |
| 93 | + | |
| 94 | + assertThat(mapper.findById(orgId).getStage()).isNull(); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void 하한_미만인_0단계는_400이다() throws Exception { | |
| 99 | + mvc.perform(put("/api/orgs/{id}/stage", orgId) | |
| 100 | + .with(csrf()) | |
| 101 | + .contentType(MediaType.APPLICATION_JSON) | |
| 102 | + .content("{\"stage\": 0}")) | |
| 103 | + .andExpect(status().isBadRequest()); | |
| 104 | + } | |
| 105 | + | |
| 106 | + @Test | |
| 107 | + void 값이_없으면_400이다() throws Exception { | |
| 108 | + mvc.perform(put("/api/orgs/{id}/stage", orgId) | |
| 109 | + .with(csrf()) | |
| 110 | + .contentType(MediaType.APPLICATION_JSON) | |
| 111 | + .content("{}")) | |
| 112 | + .andExpect(status().isBadRequest()); | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + void 존재하지_않는_기관이면_404다() throws Exception { | |
| 117 | + long missingId = orgId + 999999L; | |
| 118 | + | |
| 119 | + mvc.perform(put("/api/orgs/{id}/stage", missingId) | |
| 120 | + .with(csrf()) | |
| 121 | + .contentType(MediaType.APPLICATION_JSON) | |
| 122 | + .content("{\"stage\": 1}")) | |
| 123 | + .andExpect(status().isNotFound()); | |
| 124 | + } | |
| 125 | +} |
+++ src/test/java/kr/itn/itnhub/timeline/TimelineControllerTest.java
... | ... | @@ -0,0 +1,124 @@ |
| 1 | +package kr.itn.itnhub.timeline; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.feed.FileView; | |
| 5 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 6 | +import kr.itn.itnhub.mattermost.MattermostException; | |
| 7 | +import kr.itn.itnhub.org.Organization; | |
| 8 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 9 | +import org.junit.jupiter.api.BeforeEach; | |
| 10 | +import org.junit.jupiter.api.Test; | |
| 11 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 12 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 13 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 14 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 15 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 16 | +import org.springframework.test.web.servlet.MockMvc; | |
| 17 | + | |
| 18 | +import java.util.List; | |
| 19 | + | |
| 20 | +import static org.mockito.Mockito.when; | |
| 21 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 22 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 23 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 24 | + | |
| 25 | +@AutoConfigureMockMvc | |
| 26 | +@WithMockUser(roles = "ADMIN") | |
| 27 | +class TimelineControllerTest extends AbstractDbTest { | |
| 28 | + | |
| 29 | + @Autowired | |
| 30 | + MockMvc mvc; | |
| 31 | + | |
| 32 | + @Autowired | |
| 33 | + OrganizationMapper mapper; | |
| 34 | + | |
| 35 | + @Autowired | |
| 36 | + JdbcTemplate jdbc; | |
| 37 | + | |
| 38 | + @MockBean | |
| 39 | + MattermostClient mattermost; | |
| 40 | + | |
| 41 | + private Long orgId; | |
| 42 | + | |
| 43 | + @BeforeEach | |
| 44 | + void setUp() { | |
| 45 | + jdbc.update("delete from organization"); | |
| 46 | + | |
| 47 | + Organization org = new Organization(); | |
| 48 | + org.setOrgNo("001"); | |
| 49 | + org.setOrgName("국제방송교류재단"); | |
| 50 | + org.setChannelSlug("001"); | |
| 51 | + mapper.upsertBySeed(org); | |
| 52 | + | |
| 53 | + orgId = mapper.findAll().get(0).getId(); | |
| 54 | + mapper.updateChannelIdMj(orgId, "chan-mj"); | |
| 55 | + mapper.updateChannelIdLaw(orgId, "chan-law"); | |
| 56 | + } | |
| 57 | + | |
| 58 | + private void insertStageHistory(long orgId, int stage, long epochSeconds) { | |
| 59 | + jdbc.update("insert into stage_history (org_id, stage, changed_at) values (?, ?, to_timestamp(?))", | |
| 60 | + orgId, stage, epochSeconds); | |
| 61 | + } | |
| 62 | + | |
| 63 | + private void insertMemo(long orgId, String contactName, String body, long epochSeconds) { | |
| 64 | + jdbc.update("insert into work_memo (org_id, contact_name, body, created_at) " | |
| 65 | + + "values (?, ?, ?, to_timestamp(?))", | |
| 66 | + orgId, contactName, body, epochSeconds); | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Test | |
| 70 | + void 세_종류_이벤트를_최신순으로_합쳐서_돌려준다() throws Exception { | |
| 71 | + insertStageHistory(orgId, 1, 1_000L); | |
| 72 | + insertMemo(orgId, "송민지", "안녕하세요\n둘째줄입니다", 2_000L); | |
| 73 | + | |
| 74 | + when(mattermost.collectChannelFiles("chan-mj")).thenReturn(List.of( | |
| 75 | + new FileView("f1", "보고서.pdf", 100L, "application/pdf", 3_000_000L, "박아이티"))); | |
| 76 | + when(mattermost.collectChannelFiles("chan-law")).thenReturn(List.of()); | |
| 77 | + | |
| 78 | + mvc.perform(get("/api/orgs/{id}/timeline", orgId)) | |
| 79 | + .andExpect(status().isOk()) | |
| 80 | + .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(3))) | |
| 81 | + .andExpect(jsonPath("$[0].type").value("FILE")) | |
| 82 | + .andExpect(jsonPath("$[0].title").value("보고서.pdf")) | |
| 83 | + .andExpect(jsonPath("$[0].detail").value("문정원 · 박아이티")) | |
| 84 | + .andExpect(jsonPath("$[1].type").value("MEMO")) | |
| 85 | + .andExpect(jsonPath("$[1].title").value("송민지")) | |
| 86 | + .andExpect(jsonPath("$[1].detail").value("안녕하세요")) | |
| 87 | + .andExpect(jsonPath("$[2].type").value("STAGE")) | |
| 88 | + .andExpect(jsonPath("$[2].title").value("1")) | |
| 89 | + .andExpect(jsonPath("$[2].detail").doesNotExist()); | |
| 90 | + } | |
| 91 | + | |
| 92 | + @Test | |
| 93 | + void Mattermost_조회가_실패해도_단계와_메모_이벤트는_그대로_200으로_돌려준다() throws Exception { | |
| 94 | + insertStageHistory(orgId, 2, 1_000L); | |
| 95 | + insertMemo(orgId, "김문정", "메모내용", 2_000L); | |
| 96 | + | |
| 97 | + when(mattermost.collectChannelFiles("chan-mj")).thenThrow(new MattermostException("서버 오류")); | |
| 98 | + when(mattermost.collectChannelFiles("chan-law")).thenThrow(new MattermostException("서버 오류")); | |
| 99 | + | |
| 100 | + mvc.perform(get("/api/orgs/{id}/timeline", orgId)) | |
| 101 | + .andExpect(status().isOk()) | |
| 102 | + .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(2))) | |
| 103 | + .andExpect(jsonPath("$[0].type").value("MEMO")) | |
| 104 | + .andExpect(jsonPath("$[1].type").value("STAGE")); | |
| 105 | + } | |
| 106 | + | |
| 107 | + @Test | |
| 108 | + void 존재하지_않는_기관이면_404다() throws Exception { | |
| 109 | + long missingId = orgId + 999999L; | |
| 110 | + | |
| 111 | + mvc.perform(get("/api/orgs/{id}/timeline", missingId)) | |
| 112 | + .andExpect(status().isNotFound()); | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + void 기록이_없으면_빈_배열을_돌려준다() throws Exception { | |
| 117 | + when(mattermost.collectChannelFiles("chan-mj")).thenReturn(List.of()); | |
| 118 | + when(mattermost.collectChannelFiles("chan-law")).thenReturn(List.of()); | |
| 119 | + | |
| 120 | + mvc.perform(get("/api/orgs/{id}/timeline", orgId)) | |
| 121 | + .andExpect(status().isOk()) | |
| 122 | + .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(0))); | |
| 123 | + } | |
| 124 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?