Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
184d82f
chore: gitignore 추가
hodoon Jul 1, 2026
92830e6
feat: ChatRoomType/ChatMessageType enum 추가
hodoon Jul 9, 2026
a0a9947
feat: ChatRoom/ChatMessage에 type·workspaceId 추가, 그룹방 팩토리
hodoon Jul 9, 2026
f39e63c
feat: ChatRoomMember 엔티티·리포지토리 추가
hodoon Jul 9, 2026
18c8e60
feat: 채팅 통합 스키마 마이그레이션(V4)
hodoon Jul 9, 2026
64dec7b
feat: 업장 단톡 멤버십 동기화 UseCase(join/leave/createGroupRoom)
hodoon Jul 9, 2026
15c7fb2
feat: 업장 생성 시 그룹 단톡 자동 생성 및 매니저 입장
hodoon Jul 9, 2026
ab5660b
feat: 워커 가입/퇴사 시 업장 단톡 멤버십 자동 동기화
hodoon Jul 9, 2026
e717f92
feat: 통합 전송 UseCase - NOTICE 권한·그룹 멤버 참여자 검증
hodoon Jul 9, 2026
2d5486d
feat: 채팅방 읽음 처리 UseCase 및 엔드포인트
hodoon Jul 9, 2026
93a122d
fix: 업장 단톡 매니저 입장 시 참여자 id를 User id로 정정
hodoon Jul 9, 2026
cfca2cc
feat: 메시지별 안 읽은 사람 수 집계 및 응답 반영
hodoon Jul 9, 2026
3e39944
feat: Redis 기반 채팅 presence 저장소
hodoon Jul 9, 2026
c9d48cb
feat: STOMP 연결/해제 이벤트로 presence 갱신
hodoon Jul 9, 2026
42cb0d0
feat: presence 기반 푸시 폴백 - 오프라인 멤버에게만 FCM
hodoon Jul 9, 2026
01dd9f9
feat: STOMP 브로커 RabbitMQ relay 토글 지원(기본 SimpleBroker 유지)
hodoon Jul 9, 2026
bb61493
chore: 오배치된 local-infra 제거 (실제 local-infra는 워크스페이스 루트에 위치)
hodoon Jul 9, 2026
9379f85
feat: 이미지-only 메시지 위해 chat_messages.content nullable 전환
hodoon Jul 9, 2026
d00f9b4
feat: 채팅 전송 요청에 fileIds 추가 및 첨부 관련 ErrorCode
hodoon Jul 9, 2026
581d7ad
feat: 채팅 메시지 이미지 첨부 연결 및 실시간 payload에 URL 포함
hodoon Jul 9, 2026
4ccbee5
feat: 채팅 메시지 조회 응답에 이미지 첨부 포함
hodoon Jul 9, 2026
96f86aa
feat: 이미지 메시지 푸시 알림 본문 처리
hodoon Jul 9, 2026
55e7d6a
fix: 새 1:1 채팅방 생성 시 chat_room_members 행 생성 (읽음/안읽은수 정상화)
hodoon Jul 9, 2026
97956ee
fix: 업장 단톡 멤버십 동기화 self-healing (레거시 업장 채용/퇴사 롤백 방지)
hodoon Jul 9, 2026
cd34c95
fix: 그룹 채팅 메시지 조회 인증 dual-path (그룹 멤버 히스토리 조회 가능)
hodoon Jul 9, 2026
7832c92
feat: 업장 그룹 채팅방 조회(discovery) 엔드포인트
hodoon Jul 9, 2026
e06b4a5
refactor: 업장 채팅 멤버십 동기화를 AFTER_COMMIT 이벤트로 분리(코어 트랜잭션 롤백 방지)
hodoon Jul 9, 2026
b379d92
fix: STOMP relay용 reactor-netty 의존성 정정 (reactor-netty-core→reactor-ne…
hodoon Jul 9, 2026
682bf97
feat: 기존 업장 그룹 단톡방·멤버 백필 마이그레이션(V6)
hodoon Jul 9, 2026
3c3e295
fix: [ALT-270] 코드 리뷰 반영 — 읽음 요청 @Valid, 그룹방 유니크 제약, 첨부 setter 범위
hodoon Jul 10, 2026
07f302a
fix: [ALT-270] 그룹방 조회 fetchOne→fetchFirst (중복 데이터에도 예외 방지)
hodoon Jul 10, 2026
d2ad9d8
fix: [ALT-270] ChatRoom.createGroup workspaceId null 검증 (도메인 불변식)
hodoon Jul 10, 2026
eee228e
fix: [ALT-270] presence 다중 세션 참조카운트 — 마지막 세션 종료 시에만 오프라인
hodoon Jul 10, 2026
c429f6f
test: [ALT-270] 채팅 관련 테스트 클래스 네이밍 *Test → *Tests 통일
hodoon Jul 10, 2026
d1aafb5
test: [ALT-270] 채팅 테스트 BDD 스타일 통일 (when→given)
hodoon Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ CLAUDE.md
.codex
.agents/*
AGENTS.md

# graphify 지식그래프 산출물
graphify-out/
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ dependencies {
implementation 'software.amazon.awssdk:s3:2.34.0'
implementation 'com.fasterxml.uuid:java-uuid-generator:5.1.0'

// STOMP broker relay (RabbitMQ) — 런타임 relay 연결용
implementation 'io.projectreactor.netty:reactor-netty'

compileOnly 'org.projectlombok:lombok'

developmentOnly 'org.springframework.boot:spring-boot-devtools'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@
import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto;
import com.dreamteam.alter.application.aop.AppActionContext;
import com.dreamteam.alter.domain.auth.type.TokenScope;
import com.dreamteam.alter.domain.chat.port.inbound.CreateOrGetChatRoomUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.GetChatMessagesUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.GetChatRoomInfoUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.GetMyChatRoomListUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.GetWorkspaceGroupChatRoomUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase;
import com.dreamteam.alter.domain.user.context.AppActor;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
Expand All @@ -40,6 +45,12 @@ public class UserChatController implements UserChatControllerSpec {
@Resource(name = "getChatRoomInfo")
private final GetChatRoomInfoUseCase getChatRoomInfo;

@Resource(name = "markChatRoomRead")
private final MarkChatRoomReadUseCase markChatRoomRead;

@Resource(name = "getWorkspaceGroupChatRoom")
private final GetWorkspaceGroupChatRoomUseCase getWorkspaceGroupChatRoom;

@Override
@PostMapping("/rooms")
public ResponseEntity<CommonApiResponse<CreateChatRoomResponseDto>> createOrGetChatRoom(
Expand Down Expand Up @@ -78,4 +89,25 @@ public ResponseEntity<CursorPaginatedApiResponse<ChatMessageResponseDto>> getCha
AppActor actor = AppActionContext.getInstance().getActor();
return ResponseEntity.ok(getChatMessages.execute(actor, chatRoomId, pageRequest));
}

@Override
@PostMapping("/rooms/{chatRoomId}/read")
public ResponseEntity<CommonApiResponse<Void>> markChatRoomRead(
@PathVariable Long chatRoomId,
@Valid @RequestBody MarkChatRoomReadRequestDto request
) {
AppActor actor = AppActionContext.getInstance().getActor();
markChatRoomRead.execute(actor.getUserId(), TokenScope.APP, chatRoomId, request.getLastReadMessageId());
return ResponseEntity.ok(CommonApiResponse.empty());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Override
@GetMapping("/workspace/{workspaceId}/room")
public ResponseEntity<CommonApiResponse<CreateChatRoomResponseDto>> getWorkspaceGroupChatRoom(
@PathVariable Long workspaceId
) {
AppActor actor = AppActionContext.getInstance().getActor();
Long roomId = getWorkspaceGroupChatRoom.execute(actor.getUserId(), TokenScope.APP, workspaceId);
return ResponseEntity.ok(CommonApiResponse.of(CreateChatRoomResponseDto.of(roomId)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
Expand Down Expand Up @@ -52,4 +53,21 @@ ResponseEntity<CursorPaginatedApiResponse<ChatMessageResponseDto>> getChatMessag
@PathVariable Long chatRoomId,
CursorPageRequestDto pageRequest
);

@Operation(summary = "채팅방 읽음 처리", description = "특정 채팅방을 마지막으로 읽은 메시지까지 읽음 처리합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "읽음 처리 성공")
})
ResponseEntity<CommonApiResponse<Void>> markChatRoomRead(
@PathVariable Long chatRoomId,
@Valid @RequestBody MarkChatRoomReadRequestDto request
);

@Operation(summary = "업장 그룹 채팅방 조회", description = "업장의 그룹 채팅방 ID를 조회합니다. 해당 채팅방의 활성 멤버인 경우에만 조회됩니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "업장 그룹 채팅방 조회 성공")
})
ResponseEntity<CommonApiResponse<CreateChatRoomResponseDto>> getWorkspaceGroupChatRoom(
@PathVariable Long workspaceId
);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.dreamteam.alter.adapter.inbound.general.chat.dto;

import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto;
import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto;
import com.dreamteam.alter.adapter.outbound.chat.persistence.readonly.ChatMessageResponse;
import com.dreamteam.alter.domain.auth.type.TokenScope;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;

import java.time.LocalDateTime;
import java.util.List;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
Expand All @@ -33,6 +35,12 @@ public class ChatMessageResponseDto {
@Schema(description = "본인이 보낸 메시지 여부")
private Boolean isMine;

@Schema(description = "안 읽은 사람 수")
private Integer unreadCount;

@Schema(description = "첨부 파일 목록")
private List<FileResponseDto> attachments;

public static ChatMessageResponseDto from(ChatMessageResponse response) {
return ChatMessageResponseDto.builder()
.id(response.getId())
Expand All @@ -58,4 +66,25 @@ public static ChatMessageResponseDto from(ChatMessageResponse response, Long cur
.build();
}

public static ChatMessageResponseDto from(
ChatMessageResponse response,
Long currentUserId,
TokenScope currentUserScope,
Integer unreadCount
) {
boolean isMine = response.getSenderId().equals(currentUserId)
&& response.getSenderScope().equals(currentUserScope);

return ChatMessageResponseDto.builder()
.id(response.getId())
.senderId(response.getSenderId())
.senderScope(DescribedEnumDto.of(response.getSenderScope(), TokenScope.describe()))
.content(response.getContent())
.createdAt(response.getCreatedAt())
.isMine(isMine)
.unreadCount(unreadCount)
.attachments(response.getAttachments())
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.dreamteam.alter.adapter.inbound.general.chat.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "채팅방 읽음 처리 요청 DTO")
public class MarkChatRoomReadRequestDto {

@NotNull
@Schema(description = "마지막으로 읽은 메시지 ID")
private Long lastReadMessageId;

}
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
package com.dreamteam.alter.adapter.inbound.general.chat.dto;

import com.dreamteam.alter.domain.chat.type.ChatMessageType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import lombok.*;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "채팅 메시지 전송 요청 DTO")
public class SendChatMessageRequestDto {
@NotBlank
public static final int MAX_ATTACHMENTS = 10;

@Schema(description = "메시지 내용")
private String content;

@Schema(description = "메시지 타입(NORMAL/NOTICE), 기본 NORMAL)")
private ChatMessageType type;

@Schema(description = "첨부 이미지 fileId 목록", example = "[\"...\"]")
private List<String> fileIds;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@
import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto;
import com.dreamteam.alter.application.aop.ManagerActionContext;
import com.dreamteam.alter.domain.auth.type.TokenScope;
import com.dreamteam.alter.domain.chat.port.inbound.GetWorkspaceGroupChatRoomUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.ManagerCreateOrGetChatRoomUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatMessagesUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatRoomUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetMyChatRoomListUseCase;
import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase;
import com.dreamteam.alter.domain.user.context.ManagerActor;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
Expand All @@ -40,6 +45,12 @@ public class ManagerChatController implements ManagerChatControllerSpec {
@Resource(name = "managerGetChatRoom")
private final ManagerGetChatRoomUseCase managerGetChatRoomUseCase;

@Resource(name = "markChatRoomRead")
private final MarkChatRoomReadUseCase markChatRoomRead;

@Resource(name = "getWorkspaceGroupChatRoom")
private final GetWorkspaceGroupChatRoomUseCase getWorkspaceGroupChatRoom;

@Override
@PostMapping("/rooms")
public ResponseEntity<CommonApiResponse<CreateChatRoomResponseDto>> createOrGetChatRoom(
Expand Down Expand Up @@ -77,4 +88,25 @@ public ResponseEntity<CursorPaginatedApiResponse<ChatMessageResponseDto>> getCha
ManagerActor actor = ManagerActionContext.getInstance().getActor();
return ResponseEntity.ok(managerGetChatMessagesUseCase.execute(actor, chatRoomId, pageRequest));
}

@Override
@PostMapping("/rooms/{chatRoomId}/read")
public ResponseEntity<CommonApiResponse<Void>> markChatRoomRead(
@PathVariable Long chatRoomId,
@Valid @RequestBody MarkChatRoomReadRequestDto request
) {
ManagerActor actor = ManagerActionContext.getInstance().getActor();
markChatRoomRead.execute(actor.getUserId(), TokenScope.MANAGER, chatRoomId, request.getLastReadMessageId());
return ResponseEntity.ok(CommonApiResponse.empty());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Override
@GetMapping("/workspace/{workspaceId}/room")
public ResponseEntity<CommonApiResponse<CreateChatRoomResponseDto>> getWorkspaceGroupChatRoom(
@PathVariable Long workspaceId
) {
ManagerActor actor = ManagerActionContext.getInstance().getActor();
Long roomId = getWorkspaceGroupChatRoom.execute(actor.getUserId(), TokenScope.MANAGER, workspaceId);
return ResponseEntity.ok(CommonApiResponse.of(CreateChatRoomResponseDto.of(roomId)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto;
import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
Expand Down Expand Up @@ -52,5 +53,22 @@ ResponseEntity<CursorPaginatedApiResponse<ChatMessageResponseDto>> getChatMessag
@PathVariable Long chatRoomId,
CursorPageRequestDto pageRequest
);

@Operation(summary = "채팅방 읽음 처리", description = "특정 채팅방을 마지막으로 읽은 메시지까지 읽음 처리합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "읽음 처리 성공")
})
ResponseEntity<CommonApiResponse<Void>> markChatRoomRead(
@PathVariable Long chatRoomId,
@Valid @RequestBody MarkChatRoomReadRequestDto request
);

@Operation(summary = "업장 그룹 채팅방 조회", description = "업장의 그룹 채팅방 ID를 조회합니다. 해당 채팅방의 활성 멤버인 경우에만 조회됩니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "업장 그룹 채팅방 조회 성공")
})
ResponseEntity<CommonApiResponse<CreateChatRoomResponseDto>> getWorkspaceGroupChatRoom(
@PathVariable Long workspaceId
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dreamteam.alter.adapter.outbound.chat.persistence;

import com.dreamteam.alter.domain.chat.entity.ChatRoomMember;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ChatRoomMemberJpaRepository extends JpaRepository<ChatRoomMember, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.dreamteam.alter.adapter.outbound.chat.persistence;

import com.dreamteam.alter.domain.auth.type.TokenScope;
import com.dreamteam.alter.domain.chat.entity.ChatRoomMember;
import com.dreamteam.alter.domain.chat.entity.QChatRoomMember;
import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class ChatRoomMemberQueryRepositoryImpl implements ChatRoomMemberQueryRepository {

private final JPAQueryFactory queryFactory;

@Override
public List<ChatRoomMember> findActiveByRoom(Long chatRoomId) {
QChatRoomMember m = QChatRoomMember.chatRoomMember;
return queryFactory.selectFrom(m)
.where(m.chatRoomId.eq(chatRoomId), m.leftAt.isNull())
.fetch();
}

@Override
public Optional<ChatRoomMember> findByRoomAndMember(Long chatRoomId, Long memberId, TokenScope memberScope) {
QChatRoomMember m = QChatRoomMember.chatRoomMember;
return Optional.ofNullable(
queryFactory.selectFrom(m)
.where(m.chatRoomId.eq(chatRoomId), m.memberId.eq(memberId), m.memberScope.eq(memberScope))
.fetchOne()
);
}

@Override
public boolean existsActive(Long chatRoomId, Long memberId, TokenScope memberScope) {
QChatRoomMember m = QChatRoomMember.chatRoomMember;
Integer hit = queryFactory.selectOne().from(m)
.where(m.chatRoomId.eq(chatRoomId), m.memberId.eq(memberId),
m.memberScope.eq(memberScope), m.leftAt.isNull())
.fetchFirst();
return hit != null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.dreamteam.alter.adapter.outbound.chat.persistence;

import com.dreamteam.alter.domain.chat.entity.ChatRoomMember;
import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class ChatRoomMemberRepositoryImpl implements ChatRoomMemberRepository {

private final ChatRoomMemberJpaRepository jpaRepository;

@Override
public ChatRoomMember save(ChatRoomMember member) {
return jpaRepository.save(member);
}

@Override
public List<ChatRoomMember> saveAll(List<ChatRoomMember> members) {
return jpaRepository.saveAll(members);
}
}
Loading
Loading