File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
package kr.itn.itnhub.feed;
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 kr.itn.itnhub.org.OrgNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 기관관리 상세 화면의 채팅 탭이 쓰는 서비스. 조회뿐 아니라 메시지 전송·공지 등록/해제까지
* 담당한다. Mattermost를 매번 실시간으로 읽고 쓰는 통로이므로 {@code @Transactional}을
* 걸지 않는다 - DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을
* 트랜잭션 안에 묶어둘 이유가 없다.
*
* <p>공지 정책(정책 A - 단일 활성 공지)은 {@link #sendMessage}에서 처리한다: 게시글을
* 올린 뒤, 기존에 고정돼 있던 게시글을 전부 해제하고, 새 게시글을 고정하고, 채널 헤더를
* 공지 요약 + 원문 permalink로 바꾼다. 게시글 전송 자체가 아니라 이 뒤처리(고정/헤더) 중
* 하나라도 실패하면 - 게시글은 이미 채널 멤버에게 전달된 뒤이므로 - 실패로 보고하지 않고
* {@link SendResult#noticeWarning()}에 경고만 담아 돌려준다.</p>
*/
@Service
public class ChannelFeedService {
/** 게시물 탭 한 번에 보여줄 최근 게시글 수. */
private static final int RECENT_POSTS_PAGE_SIZE = 60;
/** 공지 헤더에 넣을 요약문 최대 길이(첫 줄 기준). */
private static final int NOTICE_SUMMARY_MAX_LEN = 150;
private final OrganizationMapper orgMapper;
private final MattermostClient mattermost;
private final MattermostProperties mattermostProps;
private final kr.itn.itnhub.stage.StageService stageService;
public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost,
MattermostProperties mattermostProps,
kr.itn.itnhub.stage.StageService stageService) {
this.orgMapper = orgMapper;
this.mattermost = mattermost;
this.mattermostProps = mattermostProps;
this.stageService = stageService;
}
public List<PostView> posts(Long orgId, String channel) {
String channelId = resolveChannelId(orgId, channel);
return mattermost.getRecentPosts(channelId, RECENT_POSTS_PAGE_SIZE);
}
public List<FileView> files(Long orgId, String channel) {
String channelId = resolveChannelId(orgId, channel);
return mattermost.collectChannelFiles(channelId);
}
/**
* 메시지를 보내고, {@code notice}가 참이면 정책 A(단일 활성 공지)를 적용한다.
* 파일 업로드·게시글 생성이 실패하면 그대로 예외가 전파된다(전송 자체가 실패한 것).
*/
public SendResult sendMessage(Long orgId, String channel, String message,
List<UploadedFile> files, boolean notice) {
String channelId = resolveChannelId(orgId, channel);
List<String> fileIds = new ArrayList<>();
for (UploadedFile file : files) {
fileIds.add(mattermost.uploadFile(channelId, file.filename(), file.data(), file.contentType()));
}
String postId = mattermost.createPost(channelId, message, fileIds);
PostView post = fetchPost(channelId, postId, message);
// 요구사항 [18]: 채널에 자료가 올라가면 ②목록접수로 저절로 넘어간다.
// 파일이 실린 게시글만 계기로 본다 - 그냥 대화만 오간 것은 자료 접수가 아니다.
if (!fileIds.isEmpty()) {
stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED,
kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED);
}
if (!notice) {
return new SendResult(post, null);
}
try {
for (PostView pinned : mattermost.getPinnedPosts(channelId)) {
mattermost.unpinPost(pinned.id());
}
mattermost.pinPost(postId);
mattermost.updateChannelHeader(channelId, noticeHeader(message, files, postId));
return new SendResult(post, null);
} catch (MattermostException e) {
return new SendResult(post, "공지 등록 처리 중 일부가 실패했습니다: " + e.getMessage());
}
}
/** 채널의 "현재 공지" = 가장 최근에 고정된, 시스템 메시지가 아닌 게시글. 없으면 null. */
public PostView currentNotice(Long orgId, String channel) {
String channelId = resolveChannelId(orgId, channel);
return mattermost.getPinnedPosts(channelId).stream()
.filter(p -> !p.system())
.findFirst()
.orElse(null);
}
/** 공지 해제: 고정된 게시글을 전부 해제하고 헤더를 비운다. 원본 글은 채팅 기록에 남는다. */
public void clearNotice(Long orgId, String channel) {
String channelId = resolveChannelId(orgId, channel);
for (PostView pinned : mattermost.getPinnedPosts(channelId)) {
mattermost.unpinPost(pinned.id());
}
mattermost.updateChannelHeader(channelId, "");
}
/**
* 방금 만든 게시글을 다시 조회해 표시용 정보(작성자 표시명 등)까지 채워진 {@link PostView}로
* 돌려준다. 조회가 비어 있는 예외적인 경우를 대비해 최소한의 값으로 대체한다.
*/
private PostView fetchPost(String channelId, String postId, String message) {
return mattermost.getRecentPosts(channelId, 1).stream()
.filter(p -> p.id().equals(postId))
.findFirst()
.orElseGet(() -> new PostView(postId, "", message == null ? "" : message,
System.currentTimeMillis(), false, List.of()));
}
/**
* 공지 헤더 문자열: {@code 📢 공지: {요약} — [자세히]({permalink})}.
* 요약은 메시지 첫 줄을 150자로 자른 값이고, 메시지가 비어 있으면 첫 첨부파일 이름을 쓴다.
*/
private String noticeHeader(String message, List<UploadedFile> files, String postId) {
String summary = noticeSummary(message, files);
String permalink = mattermostProps.baseUrl() + "/" + mattermostProps.teamName() + "/pl/" + postId;
return "📢 공지: " + summary + " — [자세히](" + permalink + ")";
}
private String noticeSummary(String message, List<UploadedFile> files) {
String firstLine = message == null ? "" : message.strip().split("\\R", 2)[0];
if (firstLine.isBlank() && !files.isEmpty()) {
firstLine = files.get(0).filename();
}
if (firstLine.length() > NOTICE_SUMMARY_MAX_LEN) {
firstLine = firstLine.substring(0, NOTICE_SUMMARY_MAX_LEN);
}
return firstLine;
}
private String resolveChannelId(Long orgId, String channel) {
Organization org = orgMapper.findById(orgId);
if (org == null) {
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
}
String channelId = switch (channel) {
case "mj" -> org.getChannelIdMj();
case "law" -> org.getChannelIdLaw();
default -> throw new ChannelNotReadyException("알 수 없는 채널 구분입니다: " + channel);
};
if (channelId == null || channelId.isBlank()) {
throw new ChannelNotReadyException("해당 채널이 아직 생성되지 않았습니다.");
}
return channelId;
}
}