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.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이 계정 존재를 먼저 확인하고 채널 추가도
* 멱등이라 재실행해도 안전하다.
*
* <p>이 훅도 postIntro와 같은 원칙을 따른다: 담당자 한 명(또는 채널 추가 한 건)의 실패가
* 나머지 처리를 막지 않고, provision() 결과(CREATED/RECOVERED/CACHED/FAILED)도 뒤집지
* 않는다 - 실패는 messages에만 남는다.</p>
*/
private void ensureMembers(Long orgId, StringBuilder messages) {
Organization org = mapper.findById(orgId);
if (org == null) {
return;
}
Map<String, List<Contact>> channelContacts = new LinkedHashMap<>();
addChannelContacts(channelContacts, org.getChannelIdMj(),
org.getApplicant(), org.getMj(), org.getItn());
addChannelContacts(channelContacts, org.getChannelIdLaw(),
org.getLawyer(), org.getMj(), org.getItn());
// 이메일(대소문자 무시) 기준으로 담당자를 중복 없이 모은다 - 같은 사람이 양쪽
// 채널(예: 문정원 담당자)에 동시에 배정될 수 있으므로 계정 조회/생성은 한 번만 한다.
Map<String, Contact> distinctContacts = new LinkedHashMap<>();
for (List<Contact> contacts : channelContacts.values()) {
for (Contact contact : contacts) {
distinctContacts.putIfAbsent(contact.getEmail().toLowerCase(), contact);
}
}
Map<String, String> 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<String, List<Contact>> 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<String, List<Contact>> channelContacts,
String channelId, Contact... candidates) {
if (!filled(channelId)) {
return;
}
List<Contact> 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(신청)로 자동 시작한다. 이미 단계가 있는 기관은 건드리지 않는다
* (재실행 안전성 - 사람이 수동으로 바꿔둔 단계를 채널 재확인이 되돌리면 안 된다).
*
* <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);
// 이것도 프로그램이 저절로 넣는 단계다(요구사항 [19]: 이력에 자동/수동 구분).
stageHistoryMapper.insert(orgId, 1, true, "채널 생성");
}
} 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<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);
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 채널에는 올리지 않는다 - 재실행할 때마다 안내글이 쌓이면 안 되고,
* 기존 채널에는 이미 안내글이 있을 수 있기 때문이다.
*
* <p>실패해도 채널 생성 결과(CREATED)를 뒤집지 않는다 - 채널은 이미 만들어졌으므로
* 안내글만 나중에 수동으로 올리면 되는 문제다. 메시지에 경고만 남긴다.</p>
*/
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);
};
}
}