diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..e0c4351
Binary files /dev/null and b/.DS_Store differ
diff --git a/.dockerignore b/.dockerignore
index f0ca959..df9caa3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1 +1,3 @@
-/src/main/resources/secret/
\ No newline at end of file
+/src/main/resources/secret/
+/src/main/resources/firebase/
+/.env
\ No newline at end of file
diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml
index 9718792..7c2dcc9 100644
--- a/.github/workflows/ci_cd.yml
+++ b/.github/workflows/ci_cd.yml
@@ -2,7 +2,7 @@ name: CI/CD Pipeline
on:
push:
- branches: [ main ] # develop
+ branches: [ develop ]
workflow_dispatch:
jobs:
@@ -55,12 +55,12 @@ jobs:
# repo clone / pull
if [ ! -d /opt/your-app ]; then
- git clone --branch main https://github.com/Alzheimer-dinger/BE.git /opt/your-app
- # git clone --branch develop https://github.com/Alzheimer-dinger/BE.git /opt/your-app
+ # git clone --branch main https://github.com/Alzheimer-dinger/BE.git /opt/your-app
+ git clone --branch develop https://github.com/Alzheimer-dinger/BE.git /opt/your-app
else
cd /opt/your-app
- git pull origin main
- # git pull origin develop
+ # git pull origin main
+ git pull origin develop
fi
# 작업 디렉토리 이동
@@ -102,4 +102,4 @@ jobs:
# 4) Docker Compose로 배포
docker-compose pull springboot
- docker-compose up -d --remove-orphans
+ docker-compose up -d --remove-orphans
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 0a2b520..d983265 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,9 +6,11 @@ build/
!**/src/test/**/build/
/logs/
/src/main/resources/secret/
+/src/main/resources/firebase/
+/.env
---data-binary
--H
+/--data-binary
+/-H
### STS ###
.apt_generated
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3dbd2d3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,848 @@
+
+
+
+
+ A-dinger (알츠하이머딩거) — 치매 환자 케어 웹앱
+
+
+
+
+
+
+
+
+ 보호자–환자 연결, 통화 기록 분석, 감정 리포트, 리마인더와 알림을 제공하는 치매 환자 케어 서비스
+
+
+
+
+
+
+
+
+
+📒 목차
+
+ 프로젝트 소개
+ |
+ 팀원 구성
+ |
+ 기술 스택
+ |
+ 저장소·브랜치 전략·구조
+ |
+ 개발 기간·작업 관리
+ |
+ 신경 쓴 부분
+ |
+ 페이지별 기능
+ |
+ 주요 API
+
+
+맨 위로 ⤴
+
+
+
+
+
+
+ 📖 프로젝트 소개
+
+
+ 본 프로젝트는 치매 환자와 보호자를 위한 AI 동반 케어 웹앱 입니다.
+ 환자는 앱에서 인공지능과 실시간 대화(음성/자막) 로 일상을 공유하고,
+ 보호자는 연결 계정을 통해 심리 상태와 이상 징후를 모니터링합니다.
+ 하루하루 축적되는 대화·활동 데이터를 분석해 일·주·월 단위 종합 리포트
+ (감정 타임라인, 참여도, 평균 통화시간, 위험 지표)를 제공하여 세심한 돌봄 계획 수립을 돕습니다.
+
+
+
+ 원클릭 통화(대기 → 진행 → 종료), 실시간 자막/응답
+ RAG 메모리 로 개인 맥락 유지, 토큰 효율 최적화
+ 보호자–환자 관계 관리 (요청/승인/해제) 및 리마인더/알림
+ PWA/FCM 기반 푸시 알림, 웹 대시보드로 리포트 열람
+ 운영/모니터링: Micrometer + Prometheus + Grafana
+
+
+
+
+
+
+
+ 👥 팀원 구성
+
+
+
+
+
+
+
+ 정장우
+
+ 팀 리더 · 백엔드 주요 도메인 · 인프라 구축
+
+
+
+
+ 김경규
+
+ 백엔드 도메인 · 인증/인가 시스템/인프라 설계
+
+
+
+
+ 박영두
+
+ 백엔드 도메인 · 인프라 구축 · CI/CD · 모니터링
+
+
+
+
+ 노예원
+
+ 프론트 UI/UX · 통화 WebSocket · CD · FCM
+
+
+
+
+
+
+ 김효신
+
+ 프론트 UI/UX · API 연동 · 상태관리
+
+
+
+
+ 서현교
+
+ AI 아이디어 · RAG 메모리 · 분석 리포트
+
+
+
+
+ 강민재
+
+ AI 실시간 통화 · 감정 분석·요약
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🔑 주요 API (요약)
+
+ 전체 스펙은 Swagger에서 확인: https://api.alzheimerdinger.com/swagger-ui/index.html#/
+
+
+
+
+ Method
+ Endpoint
+ 설명
+ 인증
+
+
+
+ POST /api/users/sign-up회원가입(Guardian/Patient, 선택: 환자코드) ❌
+ POST /api/users/login로그인(JWT Access/Refresh 발급, FCM 토큰 접수) ❌
+ DELETE /api/users/logout로그아웃(토큰 무효화) ✅
+ POST /api/token토큰 재발급(refreshToken 쿼리) ✅
+ GET /api/users/profile프로필 조회 ✅
+ PATCH /api/users/profile프로필 수정(이름/성별/비밀번호) ✅
+ GET /api/images/profile/upload-urlGCS Presigned 업로드 URL 발급(extension) ✅
+ POST /api/images/profile업로드 파일을 프로필 이미지로 적용(fileKey) ✅
+ POST /api/relations/send관계 요청 전송(patientCode) ✅
+ POST /api/relations/resend만료 요청 재전송(relationId) ✅
+ PATCH /api/relations/reply관계 요청 응답(relationId, status) ✅
+ GET /api/relations관계 목록 조회 ✅
+ DELETE /api/relations관계 해제(relationId) ✅
+ GET /api/reminder리마인더 조회 ✅
+ POST /api/reminder리마인더 등록(fireTime, status) ✅
+ GET /api/transcripts통화 기록 목록(요약) ✅
+ GET /api/transcripts/{sessionId}통화 기록 상세(요약/대화 로그) ✅
+ GET /api/analysis/report/latest최근 분석 리포트(periodEnd, userId) ✅
+ GET /api/analysis/period기간별 감정 분석(start, end, userId) ✅
+ GET /api/analysis/day일별 감정 분석(date, userId) ✅
+ POST /api/feedback피드백 저장(rating, reason) ✅
+
+
+
+ 참고: 실시간 통화(음성/자막) 은 클라이언트 ↔ AI 서버(WebSocket/Streaming) 연결을 통해 처리되며, 백엔드는 세션/기록/리포트 API를 제공합니다.
+
+ 맨 위로 ⤴
+
+
+
+
+ 📦 저장소 · 브랜치 전략 · 프로젝트 구조
+
+
+ GitHub :
+ https://github.com/Alzheimer-dinger
+
+
+브랜치 전략 (Git-flow 기반)
+
+ main — 배포용 안정 브랜치. 태깅(vX.Y.Z) 후 배포.
+ develop — 통합 개발 브랜치. 기능/버그 픽스 머지 대상.
+ feature/<scope>-<short-desc> — 기능 단위 작업. 완료 시 PR → develop.
+ hotfix/<issue> — 긴급 수정. PR → main 및 develop 양쪽 반영.
+ release/<version> — 릴리즈 준비(버전, 문서, 마이그레이션) 후 main 병합.
+
+
+PR 규칙
+
+ PR 템플릿 사용: 배경/변경점/테스트/스크린샷/체크리스트 포함
+ 리뷰 1명 이상 승인(🚦 최소 1 Approve), CI 통과 필수
+ 라벨: feature, fix, refactor 등
+
+
+커밋 컨벤션 (Conventional Commits)
+ feat(auth): add refresh token rotation
+fix(api): handle null imageUrl in profile response
+refactor(ui): split ReportChart into small components
+docs(readme): add tech stack badges
+chore(ci): bump node to 20.x in workflow
+
+
+프로젝트 구조
+ /
+├─ BE/
+│ ├─ build.gradle
+│ ├─ src/main/java/opensource/alzheimerdinger/core
+│ │ ├─ global/
+│ │ └─ domain/
+│ │ ├─ user/
+│ │ ├─ image/
+│ │ ├─ relation/
+│ │ ├─ reminder/
+│ │ ├─ transcript/
+│ │ ├─ analysis/
+│ │ └─ feedback/
+│ └─ src/main/resources/
+│
+├─ FE/
+│ ├─ package.json
+│ └─ src/
+│
+└─infra/
+ ├─ docker-compose.yml
+ ├─ nginx/
+ ├─ prometheus/
+ └─ grafana/
+
+
+
+
+
+
+
+ 🧩 도메인 예시: user
+
+ 아래는 user 도메인의 대표 구성요소를 간단히 요약한 예시입니다.
+ 전체 코드는 레포지토리에서 확인하세요.
+
+
+
+
+ 1) DTO · Request
+
+ // LoginRequest.java
+ public record LoginRequest(
+ @Email @NotBlank String email,
+ @NotBlank String password,
+ @NotNull String fcmToken
+ ) {}
+
+ // SignUpRequest.java
+ public record SignUpRequest(
+ @NotBlank String name,
+ @Email @NotBlank String email,
+ @NotBlank String password,
+ @NotNull Gender gender,
+ String patientCode
+ ) {}
+
+ // UpdateProfileRequest.java
+ public record UpdateProfileRequest(
+ @NotBlank String name,
+ @NotNull Gender gender,
+ String currentPassword,
+ String newPassword
+ ) {
+ @AssertTrue(message = "currentPassword is required when newPassword is provided")
+ public boolean isPasswordChangeValid() {
+ if (newPassword == null || newPassword.isBlank()) return true;
+ return currentPassword != null && !currentPassword.isBlank();
+ }
+ }
+ }
+
+
+
+
+ 2) DTO · Response
+
+ // LoginResponse.java
+ public record LoginResponse(String accessToken, String refreshToken) {}
+
+ // ProfileResponse.java
+ public record ProfileResponse(
+ String userId,
+ String name,
+ String email,
+ Gender gender,
+ String imageUrl,
+ String patientCode
+ ) {
+ public static ProfileResponse create(User user, String imageUrl) {
+ return new ProfileResponse(
+ user.getUserId(),
+ user.getName(),
+ user.getEmail(),
+ user.getGender(),
+ imageUrl,
+ user.getPatientCode()
+ );
+ }
+ }
+
+
+
+
+ 3) Entity
+
+ // Gender.java
+ public enum Gender { MALE, FEMALE }
+
+ // Role.java
+ @Getter
+ public enum Role {
+ GUARDIAN("ROLE_GUARDIAN"),
+ PATIENT("ROLE_PATIENT");
+ private final String name;
+ Role(String name) { this.name = name; }
+ }
+
+ // User.java
+ @Entity
+ @Table(name = "users")
+ @Getter
+ @Builder
+ @AllArgsConstructor
+ @NoArgsConstructor
+ public class User extends BaseEntity {
+ @Id @Tsid
+ private String userId;
+ private String name;
+ @Column(nullable = false)
+ private String email;
+ @Column(nullable = false)
+ private String password;
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private Role role;
+ private String patientCode;
+ @Enumerated(EnumType.STRING)
+ private Gender gender;
+
+ public void updateRole(Role role) {
+ this.role = role;
+ }
+ public void updateProfile(String name, Gender gender, String encodedNewPassword) {
+ this.name = name;
+ this.gender = gender;
+ if (encodedNewPassword != null && !encodedNewPassword.isBlank()) {
+ this.password = encodedNewPassword;
+ }
+ }
+ }
+
+
+
+
+ 4) Repository
+
+ // UserRepository.java
+ public interface UserRepository extends JpaRepository {
+
+ @Query("select count(u) > 0 from User u where u.email = :email")
+ Boolean existsByEmail(@Param("email") String email);
+
+ @Query("select u from User u where u.email = :email")
+ Optional findByEmail(@Param("email") String email);
+
+ @Query("select u from User u where u.patientCode = :patientCode")
+ Optional findByPatientCode(@Param("patientCode") String patientCode);
+ }
+
+
+
+
+ 5) Service (요약)
+
+ // UserService.java (발췌)
+ @Service
+ @RequiredArgsConstructor
+ public class UserService {
+ private static final Logger log = LoggerFactory.getLogger(UserService.class);
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final ImageService imageService;
+
+ public boolean isAlreadyRegistered(String email) {
+ return userRepository.existsByEmail(email);
+ }
+
+ public User save(SignUpRequest req, String code) {
+ return userRepository.save(
+ User.builder()
+ .email(req.email())
+ .password(passwordEncoder.encode(req.password()))
+ .role(req.patientCode() == null ? Role.PATIENT : Role.GUARDIAN)
+ .patientCode(code)
+ .gender(req.gender())
+ .name(req.name())
+ .build()
+ );
+ }
+
+ public ProfileResponse findProfile(String userId) {
+ return userRepository.findById(userId)
+ .map(u -> ProfileResponse.create(u, imageService.getProfileImageUrl(u)))
+ .orElseThrow(() -> new RestApiException(_NOT_FOUND));
+ }
+ }
+
+
+
+
+ 6) UseCase
+
+ // UpdateProfileUseCase.java (발췌)
+ @Service
+ @Transactional
+ @RequiredArgsConstructor
+ public class UpdateProfileUseCase {
+ private final UserService userService;
+ private final PasswordEncoder passwordEncoder;
+ private final ImageService imageService;
+
+ @UseCaseMetric(domain = "user-profile", value = "update-profile", type = "command")
+ public ProfileResponse update(String userId, UpdateProfileRequest req) {
+ User user = userService.findUser(userId);
+ String encodedNewPassword = null;
+
+ if (req.newPassword() != null && !req.newPassword().isBlank()) {
+ boolean matches = passwordEncoder.matches(req.currentPassword(), user.getPassword());
+ if (!matches) {
+ log.warn("[UpdateProfile] password mismatch: userId={}", userId);
+ throw new RestApiException(_UNAUTHORIZED);
+ }
+ encodedNewPassword = passwordEncoder.encode(req.newPassword());
+ }
+
+ user.updateProfile(req.name(), req.gender(), encodedNewPassword);
+ return ProfileResponse.create(user, imageService.getProfileImageUrl(user));
+ }
+ }
+
+ // UserAuthUseCase.login(...) (발췌)
+ public LoginResponse login(LoginRequest req) {
+ User user = userService.findByEmail(req.email());
+ if (!passwordEncoder.matches(req.password(), user.getPassword())) {
+ throw new RestApiException(LOGIN_ERROR);
+ }
+ String at = tokenProvider.createAccessToken(user.getUserId(), user.getRole());
+ String rt = tokenProvider.createRefreshToken(user.getUserId(), user.getRole());
+ Duration exp = tokenProvider.getRemainingDuration(rt)
+ .orElseThrow(() -> new RestApiException(EXPIRED_MEMBER_JWT));
+ refreshTokenService.saveRefreshToken(user.getUserId(), rt, exp);
+ fcmTokenService.upsert(user, req.fcmToken());
+ return new LoginResponse(at, rt);
+ }
+
+
+
+
+ 7) Controller
+
+ // AuthController.java (발췌)
+ @RestController
+ @RequiredArgsConstructor
+ @RequestMapping("/api/users")
+ public class AuthController {
+
+ private final UserAuthUseCase userAuthUseCase;
+
+ @PostMapping("/sign-up")
+ public BaseResponse<Void> signUp(@Valid @RequestBody SignUpRequest req) {
+ userAuthUseCase.signUp(req);
+ return BaseResponse.onSuccess();
+ }
+
+ @PostMapping("/login")
+ public BaseResponse<LoginResponse> login(@Valid @RequestBody LoginRequest req) {
+ return BaseResponse.onSuccess(userAuthUseCase.login(req));
+ }
+
+ @DeleteMapping("/logout")
+ public BaseResponse<Void> logout(HttpServletRequest request) {
+ userAuthUseCase.logout(request);
+ return BaseResponse.onSuccess();
+ }
+ }
+
+ // UserController.java (발췌)
+ @RestController
+ @RequiredArgsConstructor
+ @RequestMapping("/api/users")
+ @SecurityRequirement(name = "Bearer Authentication")
+ public class UserController {
+
+ private final UserProfileUseCase userProfileUseCase;
+ private final UpdateProfileUseCase updateProfileUseCase;
+
+ @GetMapping("/profile")
+ public BaseResponse getProfile(@CurrentUser String userId) {
+ return BaseResponse.onSuccess(userProfileUseCase.findProfile(userId));
+ }
+
+ @PatchMapping("/profile")
+ public BaseResponse updateProfile(
+ @CurrentUser String userId,
+ @Valid @RequestBody UpdateProfileRequest req
+ ) {
+ return BaseResponse.onSuccess(updateProfileUseCase.update(userId, req));
+ }
+ }
+
+ // TokenController.java (발췌)
+ @RestController
+ @RequiredArgsConstructor
+ @RequestMapping("/api/token")
+ @SecurityRequirement(name = "Bearer Authentication")
+ public class TokenController {
+
+ private final TokenReissueService tokenReissueService;
+
+ @PostMapping
+ public BaseResponse reissue(
+ @RefreshToken String refreshToken,
+ @CurrentUser String userId
+ ) {
+ return BaseResponse.onSuccess(tokenReissueService.reissue(refreshToken, userId));
+ }
+ }
+
+
+ ↑ 프로젝트 구조로 돌아가기
+
+
+
+
+
+
+ 🗓️ 개발 기간 · 작업 관리
+
+
+
+
+ 기간
+ 스프린트 목표
+ 주요 산출물
+
+
+
+
+ 2025-06-20 ~ 2025-07-03 (1~2주차)
+ 요구사항 정의 · API 명세 · DB 설계
+ 요구사항 정의서, ERD, Swagger 초안
+
+
+ 2025-07-04 ~ 2025-07-31 (3~6주차)
+ 핵심 기능·UI/UX 개발, RAG 구현, 프롬프트 엔지니어링
+ FE 페이지/컴포넌트, BE 도메인/인증, RAG 서비스
+
+
+ 2025-08-01 ~ 2025-08-14 (7~8주차)
+ 기능 통합·안정화 테스트
+ E2E/통합 테스트, 버그픽스, 성능/보안 점검
+
+
+ 2025-08-15 ~ 2025-08-21 (9주차)
+ 배포·모니터링·운영
+ 릴리즈 노트, 대시보드, 알림 룰
+
+
+
+
+작업 관리 방식
+
+ 이슈 추적 : GitHub Issues (템플릿: bug/feature/chore )
+ 칸반 : GitHub Projects — Backlog → In Progress → In Review → Done
+ WIP 제한 : 인당 2개(리뷰 포함), 급한 이슈는 라벨 priority:high
+ 릴리즈 : 주 1회 태깅(세맨틱 버저닝), 체인지로그 자동화
+ 품질 게이트 : CI 빌드/테스트/리포트, 린트·포맷·타입체크
+
+
+
+
+
+
+
+ 🧠 핵심 기능 구현 내용
+
+
+1) 실시간 AI 기반 통화 제공
+
+ 환자와 AI가 음성으로 대화하고, 실시간 자막을 제공하는 통화 기능을 구현했습니다.
+ 통화 전/중/후 상태를 명확히 분리하고, 오디오 스트림 처리와 스트리밍 응답을 안정적으로 연결합니다.
+
+
+① UI 흐름
+ CallWaiting → CallActive → CallEnd (종료 후 요약/저장)
+
+ CallWaiting : 장치/권한 체크(마이크), 서버 연결 준비, 상태 표시
+ CallActive : 실시간 자막(부분/최종), 발화/응답 타임라인, 음소거/종료 버튼
+ CallEnd : 통화 요약 노출, 저장/이탈 동작 분기
+
+
+② 오디오 처리
+
+ useAudioStream 훅으로 발화 감지(VAD) 및 마이크 스트림 수집
+ WebAudio / MediaDevices API 사용, 입력 레벨 모니터링 및 일시정지/재개
+ 샘플레이트/채널 정규화 → 네트워크 전송 포맷으로 인코딩(스트리밍)
+
+
+③ 실시간 연결
+
+ WebSocket 기반 양방향 스트리밍 : 오디오 업스트림, 자막/오디오 다운스트림
+ 부분/최종 자막 구분 렌더링(부분 갱신 → 최종 확정)
+ 연결 신뢰성 : 핑/퐁 헬스체크, 지수적 재시도, 일시 네트워크 단절 복구
+ 에러/예외 처리 : 인증 오류, 장치 접근 실패, 모델 과부하 시 사용자 가이드
+ 리소스 정리 : 트랙 stop, 소켓 close, 메모리 해제(종료/이탈 시)
+
+
+
+
+
+2) 사용자 맞춤형 통합 보고서
+
+ 일간/기간 종합 관점에서 감정 및 이용 지표를 시각화합니다. 날짜/기간 선택에 따라 API 파라미터를 구성하고,
+ 전처리된 데이터로 그래프/지표 컴포넌트를 렌더링합니다.
+
+
+① 일간(DailySection)
+
+ 날짜 선택 + 월간 이모지 캘린더 로 하루 흐름 빠른 탐색
+ 감정 계산 로직 : 대화 로그 기반 점수 산출(행복/슬픔/분노/놀람/권태 등)
+ 원형 스코어 게이지로 당일 상태를 직관적으로 표현
+
+
+② 종합(TotalSection)
+
+ 기간 선택 : 1주 / 1달 / 사용자 지정 범위
+ 감정 타임라인 : 날짜별 점수 추세(Recharts 라인/에어리어 차트)
+ 참여도/평균 통화시간/위험도 계산 및 카드 지표로 요약
+ EndDate 기준 종합 보고서 : 선택 범위의 말일을 기준으로 요약 문구/지표 확정
+
+
+③ 데이터 흐름(요약)
+
+ 통화 중 : 마이크 권한 → 오디오 스트림(WebSocket) 전송 → AI 응답(오디오/자막) 수신
+ 통화 후 : 세션 요약/대화 로그 서버 기록 → 분석 API가 집계/리포트 생성
+ 리포트 조회 : 사용자/연결 대상 식별 → 쿼리 파라미터 구성 → 일간/종합 API 호출 → 시각화
+
+
+
+
+
+
+
+ 🧭 페이지별 기능
+
+
+ Splash · 온보딩
+
+ 앱 로드시 스플래시 → 로그인 상태에 따라 라우팅
+ 간단 소개/권한 안내(마이크, 푸시)
+
+
+
+
+ 로그인/회원가입
+
+ 이메일·비밀번호 유효성 검사, 오류 메시지 인라인 표시
+ 회원가입 후 프로필 초기 설정(이름/성별/환자코드 옵션)
+ JWT 발급(Access/Refresh), FCM 토큰 등록
+
+
+
+
+ 프로필
+
+ 내 프로필: 이미지/이름/성별/비밀번호 수정, 판매 영역은 미사용
+ 관계(보호자-환자) 상태 표시
+
+
+
+
+ 관계 관리
+
+ 환자코드로 요청, 만료 시 재전송, 승인/거절
+ 관계 목록/해제, 상태(REQUESTED/APPROVED 등) 표시
+
+
+
+
+ 통화(실시간 AI)
+
+ 흐름: CallWaiting → CallActive → End
+ 마이크 권한, 발화 감지(useAudioStream ), WebSocket/Streaming
+ 실시간 자막/응답, 종료 후 기록 저장
+
+
+
+
+ 통화 기록(Transcripts)
+
+ 목록: 세션ID/제목/일시/지속시간 요약
+ 상세: 요약/대화 로그, 페이징/검색
+
+
+
+
+ 분석 리포트
+
+ 일간 : 날짜 선택, 월간 이모지 캘린더, 감정 점수, 원형 스코어
+ 종합 : 기간(1주/1달/사용자 지정) 선택, 감정 타임라인, 참여도/평균 통화시간/위험도
+
+
+
+
+ 리마인더
+
+ 알림 시각·상태 등록/조회(ACTIVE/INACTIVE)
+ PWA/FCM 기반 푸시
+
+
+
+
+ 설정/로그아웃
+
+ 세션 종료(토큰 무효화), 보안/알림 설정
+
+
+
+
+ 피드백
+
+ 평점(예: VERY_LOW~)과 사유 저장, 운영 개선에 활용
+
+
+
+ 맨 위로 ⤴
+
diff --git a/build.gradle b/build.gradle
index fb4f054..999aa42 100644
--- a/build.gradle
+++ b/build.gradle
@@ -33,6 +33,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation("org.springframework.boot:spring-boot-starter-aop")
// Kafka
implementation 'org.springframework.kafka:spring-kafka'
diff --git a/docker-compose.yml b/docker-compose.yml
index 845e34c..3db501d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -104,6 +104,9 @@ services:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --web.external-url=https://api.alzheimerdinger.com/prometheus/
ports:
- "9090:9090"
networks:
@@ -116,6 +119,8 @@ services:
environment:
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
+ GF_SERVER_SERVE_FROM_SUB_PATH: "true"
+ GF_SERVER_ROOT_URL: "https://api.alzheimerdinger.com/grafana/"
ports:
- "3000:3000"
networks:
diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf
index 48be4f9..4affadc 100644
--- a/nginx/conf.d/default.conf
+++ b/nginx/conf.d/default.conf
@@ -1,29 +1,76 @@
+# 80 포트 - 헬스체크만 예외, 나머지는 HTTPS에 담당
server {
listen 80;
server_name alzheimerdinger.com www.alzheimerdinger.com api.alzheimerdinger.com;
- add_header 'Access-Control-Allow-Origin' 'http://localhost:5173 http://localhost:8080' always;
- add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
- add_header 'Access-Control-Allow-Headers' 'DNT,Authorization,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Upgrade,Connection' always;
- add_header 'Access-Control-Allow-Credentials' 'true' always;
+ # CORS headers are handled by Spring Boot only
- # API & WebSocket 프록시
- location / {
- proxy_pass http://app;
+ # WebSocket 프록시
+ location /ws/ {
+ proxy_pass http://call;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
- proxy_cache_bypass $http_upgrade;
# 타임아웃
- proxy_read_timeout 3600s;
- proxy_send_timeout 3600s;
+ proxy_read_timeout 1800s;
+ proxy_send_timeout 1800s;
# 버퍼링 비활성화
proxy_buffering off;
proxy_cache_bypass $http_upgrade;
}
+
+ # API 프록시
+ location / {
+ proxy_pass http://app;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ # Grafana
+ location = /grafana { return 301 /grafana/; } # spring으로 프록시 방지
+ location /grafana/ {
+ proxy_pass http://grafana;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+
+ proxy_set_header X-Forwarded-Proto $xfp;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Prefix /grafana;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_redirect off;
+ }
+
+ # Prometheus
+ location = /prometheus { return 301 /prometheus/; } # spring으로 프록시 방지
+ location /prometheus/ {
+ proxy_pass http://prometheus;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+
+ proxy_set_header X-Forwarded-Proto $xfp;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Prefix /prometheus;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_redirect off;
+ }
}
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 5f282f6..05716a6 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -10,21 +10,26 @@ http {
default upgrade;
'' close;
}
+
+ # LB가 준 값이 있으면 그대로, 없으면 https로 간주
+ map $http_x_forwarded_proto $xfp {
+ default $http_x_forwarded_proto;
+ "" https;
+ }
upstream app {
server springboot:8080;
}
+ upstream call {
+ server 172.17.0.1:8765;
+ }
+ upstream grafana {
+ server grafana:3000;
+ }
+ upstream prometheus {
+ server prometheus:9090;
+ }
# Load server configurations from conf.d
include /etc/nginx/conf.d/*.conf;
-
- # HTTPS 설정(SSL 인증서가 있을 경우)
- # server {
- # listen 443 ssl;
- # ssl_certificate /etc/nginx/certs/fullchain.pem;
- # ssl_certificate_key /etc/nginx/certs/privkey.pem;
- # include /etc/nginx/certs/options-ssl-nginx.conf;
- # ssl_dhparam /etc/nginx/certs/ssl-dhparams.pem;
- # …위와 동일한 location / 설정…
- # }
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisDayResponse.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisDayResponse.java
index 6e9bb98..f829455 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisDayResponse.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisDayResponse.java
@@ -1,23 +1,16 @@
package opensource.alzheimerdinger.core.domain.analysis.application.dto.response;
import java.time.LocalDate;
-import java.util.List;
public record AnalysisDayResponse(
String userId,
LocalDate analysisDate,
+ boolean hasData,
Double happyScore,
Double sadScore,
Double angryScore,
Double surprisedScore,
- Double boredScore,
-
- List monthlyEmotionData
+ Double boredScore
) {
-
- public record EmotionSummary(
- LocalDate date,
- String emotionType
- ) {}
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisMonthlyEmotionResponse.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisMonthlyEmotionResponse.java
new file mode 100644
index 0000000..6bb5442
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisMonthlyEmotionResponse.java
@@ -0,0 +1,17 @@
+package opensource.alzheimerdinger.core.domain.analysis.application.dto.response;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record AnalysisMonthlyEmotionResponse(
+ String userId,
+ LocalDate month,
+ List monthlyEmotionData
+) {
+ public record EmotionSummary(
+ LocalDate date,
+ String emotionType
+ ) {}
+}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisResponse.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisResponse.java
index 125f835..a758e60 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisResponse.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/dto/response/AnalysisResponse.java
@@ -10,12 +10,12 @@ public record AnalysisResponse(
Double averageRiskScore,
- List emotionTimeline,
+ List emotionTimeline,
Integer totalParticipate,
String averageCallTime // 임시값으로 지정되어 있는 상황 AI쪽 구현 후 수정 필요
) {
- public record EmotionDataPoint(
+ public record EmotionDataScore(
LocalDate date,
Double happyScore,
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCase.java
index 9ab3304..15a5119 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCase.java
@@ -5,8 +5,10 @@
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisResponse;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisDayResponse;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisReportResponse;
+import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisMonthlyEmotionResponse;
import opensource.alzheimerdinger.core.domain.analysis.domain.entity.AnalysisReport;
import opensource.alzheimerdinger.core.domain.analysis.domain.service.AnalysisService;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@@ -20,17 +22,26 @@ public class AnalysisUseCase {
//특정 기간 감정 분석 데이터 조회
+ @UseCaseMetric(domain = "analysis", value = "get-period", type = "query")
public AnalysisResponse getAnalysisPeriodData(String userId, LocalDate start, LocalDate end) {
return analysisService.getPeriodData(userId, start, end);
}
//일별 감정 분석 데이터 조회 (달력용 데이터 포함)
+ @UseCaseMetric(domain = "analysis", value = "get-day", type = "query")
public AnalysisDayResponse getAnalysisDayData(String userId, LocalDate date) {
return analysisService.getDayData(userId, date);
}
+ // 월간 달력용 데이터 조회
+ @UseCaseMetric(domain = "analysis", value = "get-month", type = "query")
+ public AnalysisMonthlyEmotionResponse getAnalysisMonthlyEmotionData(String userId, LocalDate date) {
+ return analysisService.getMonthlyEmotionData(userId, date);
+ }
+
//기존 분석 리포트 중 가장 최근 리포트 조회
+ @UseCaseMetric(domain = "analysis", value = "get-latest-report", type = "query")
public AnalysisReportResponse getLatestReport(String userId, LocalDate periodEnd) {
AnalysisReport latestReport = analysisService.findLatestReport(userId, periodEnd);
@@ -38,7 +49,7 @@ public AnalysisReportResponse getLatestReport(String userId, LocalDate periodEnd
latestReport.getAnalysisReportId(),
userId,
latestReport.getCreatedAt().toLocalDate(),
- latestReport.getReport()
+ latestReport.getContent()
);
}
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/AnalysisReport.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/AnalysisReport.java
index a76803c..0fdbbda 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/AnalysisReport.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/AnalysisReport.java
@@ -6,21 +6,31 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
-import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.global.common.BaseEntity;
+import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
-@Table(name = "analysis_report")
+@Table(name = "reports")
public class AnalysisReport extends BaseEntity {
@Id @Tsid
+ @Column(name = "id")
private String analysisReportId;
- private String report;
+ // Self reference to base report (nullable)
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "base_report_id")
+ private AnalysisReport baseReport;
+
+ @Column(name = "session_id", nullable = false)
+ private String sessionId;
+
+ @Column(name = "content", columnDefinition = "TEXT")
+ private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/DementiaAnalysis.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/DementiaAnalysis.java
new file mode 100644
index 0000000..b2db0c5
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/DementiaAnalysis.java
@@ -0,0 +1,35 @@
+package opensource.alzheimerdinger.core.domain.analysis.domain.entity;
+
+import io.hypersistence.utils.hibernate.id.Tsid;
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
+import opensource.alzheimerdinger.core.global.common.BaseEntity;
+
+@Entity
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Table(name = "dementia_analysis")
+public class DementiaAnalysis extends BaseEntity {
+
+ @Id @Tsid
+ @Column(name = "id")
+ private String dementiaId;
+
+ @Column(name = "session_id", nullable = false)
+ private String sessionId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Column(name = "risk_score", nullable = false)
+ private double riskScore;
+}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/Analysis.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/EmotionAnalysis.java
similarity index 77%
rename from src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/Analysis.java
rename to src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/EmotionAnalysis.java
index 77055f2..89d4ba6 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/Analysis.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/entity/EmotionAnalysis.java
@@ -14,25 +14,25 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-@Table(name = "analysis_entries")
-public class Analysis extends BaseEntity {
+@Table(name = "emotion_analysis")
+public class EmotionAnalysis extends BaseEntity {
@Id @Tsid
- private String analysisId;
+ @Column(name = "id")
+ private String emotionId;
- @Column(name = "transcript_id", nullable = false)
- private String transcriptId;
+ @Column(name = "session_id", nullable = false)
+ private String sessionId;
- @Column(nullable = false)
- private double riskScore;
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
@Column(nullable = false) private double happy;
@Column(nullable = false) private double sad;
@Column(nullable = false) private double angry;
@Column(nullable = false) private double surprised;
@Column(nullable = false) private double bored;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "user_id", nullable = false)
- private User user;
}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/AnalysisReportRepository.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/AnalysisReportRepository.java
index f375598..fe8c922 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/AnalysisReportRepository.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/AnalysisReportRepository.java
@@ -11,12 +11,13 @@
public interface AnalysisReportRepository extends JpaRepository {
@Query(value = """
- SELECT ar.* FROM analysis_report ar
- JOIN users u ON ar.user_id = u.user_id
- WHERE u.user_id = :userId AND ar.created_at <= :periodEnd
- ORDER BY ar.created_at DESC
+ SELECT r.*
+ FROM reports r
+ JOIN users u ON r.user_id = u.user_id
+ WHERE u.user_id = :userId AND r.created_at <= :periodEnd
+ ORDER BY r.created_at DESC
LIMIT 1
""", nativeQuery = true)
- Optional findLatestReport(@Param("userId") String userId,
- @Param("periodEnd") LocalDateTime periodEnd);
+ Optional findLatestReport(@Param("userId") String userId,
+ @Param("periodEnd") LocalDateTime periodEnd);
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/AnalysisRepository.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/AnalysisRepository.java
deleted file mode 100644
index 727eb69..0000000
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/AnalysisRepository.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package opensource.alzheimerdinger.core.domain.analysis.domain.repository;
-
-import opensource.alzheimerdinger.core.domain.analysis.domain.entity.Analysis;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.query.Param;
-
-import java.time.LocalDateTime;
-import java.util.List;
-
-public interface AnalysisRepository extends JpaRepository {
-
- @Query("""
- SELECT a FROM Analysis a
- WHERE a.user.userId = :userId
- AND a.createdAt BETWEEN :start AND :end
- ORDER BY a.createdAt
- """)
- List findByUserAndPeriod(@Param("userId") String userId,
- @Param("start") LocalDateTime start,
- @Param("end") LocalDateTime end);
-}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/DementiaAnalysisRepository.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/DementiaAnalysisRepository.java
new file mode 100644
index 0000000..9c722e7
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/DementiaAnalysisRepository.java
@@ -0,0 +1,24 @@
+package opensource.alzheimerdinger.core.domain.analysis.domain.repository;
+
+import opensource.alzheimerdinger.core.domain.analysis.domain.entity.DementiaAnalysis;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface DementiaAnalysisRepository extends JpaRepository {
+
+ @Query("""
+ SELECT d FROM DementiaAnalysis d
+ WHERE d.user.userId = :userId
+ AND d.createdAt BETWEEN :start AND :end
+ ORDER BY d.createdAt
+ """)
+ List findByUserAndPeriod(@Param("userId") String userId,
+ @Param("start") LocalDateTime start,
+ @Param("end") LocalDateTime end);
+}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/EmotionAnalysisRepository.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/EmotionAnalysisRepository.java
new file mode 100644
index 0000000..58f3ac6
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/repository/EmotionAnalysisRepository.java
@@ -0,0 +1,24 @@
+package opensource.alzheimerdinger.core.domain.analysis.domain.repository;
+
+import opensource.alzheimerdinger.core.domain.analysis.domain.entity.EmotionAnalysis;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface EmotionAnalysisRepository extends JpaRepository {
+
+ @Query("""
+ SELECT e FROM EmotionAnalysis e
+ WHERE e.user.userId = :userId
+ AND e.createdAt BETWEEN :start AND :end
+ ORDER BY e.createdAt
+ """)
+ List findByUserAndPeriod(@Param("userId") String userId,
+ @Param("start") LocalDateTime start,
+ @Param("end") LocalDateTime end);
+}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisService.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisService.java
index aab68b5..bb6fe5b 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisService.java
@@ -3,15 +3,24 @@
import lombok.RequiredArgsConstructor;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisResponse;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisDayResponse;
-import opensource.alzheimerdinger.core.domain.analysis.domain.entity.Analysis;
+import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisMonthlyEmotionResponse;
+import opensource.alzheimerdinger.core.domain.analysis.domain.entity.EmotionAnalysis;
import opensource.alzheimerdinger.core.domain.analysis.domain.entity.AnalysisReport;
-import opensource.alzheimerdinger.core.domain.analysis.domain.repository.AnalysisRepository;
+import opensource.alzheimerdinger.core.domain.analysis.domain.entity.DementiaAnalysis;
+import opensource.alzheimerdinger.core.domain.analysis.domain.repository.EmotionAnalysisRepository;
+import opensource.alzheimerdinger.core.domain.analysis.domain.repository.DementiaAnalysisRepository;
import opensource.alzheimerdinger.core.domain.analysis.domain.repository.AnalysisReportRepository;
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.Transcript;
+import opensource.alzheimerdinger.core.domain.transcript.domain.repository.TranscriptRepository;
import opensource.alzheimerdinger.core.global.exception.RestApiException;
import org.springframework.stereotype.Service;
+import java.time.Duration;
+import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Map;
import java.util.List;
import java.util.stream.Collectors;
@@ -21,11 +30,13 @@
@RequiredArgsConstructor
public class AnalysisService {
- private final AnalysisRepository analysisRepository;
+ private final EmotionAnalysisRepository emotionAnalysisRepository;
+ private final DementiaAnalysisRepository dementiaAnalysisRepository;
private final AnalysisReportRepository analysisReportRepository;
+ private final TranscriptRepository transcriptRepository;
- public List findAnalysisData(String userId, LocalDateTime start, LocalDateTime end) {
- return analysisRepository.findByUserAndPeriod(userId, start, end);
+ public List findEmotionAnalysisData(String userId, LocalDateTime start, LocalDateTime end) {
+ return emotionAnalysisRepository.findByUserAndPeriod(userId, start, end);
}
public AnalysisResponse getPeriodData(String userId, LocalDate start, LocalDate end) {
@@ -33,31 +44,59 @@ public AnalysisResponse getPeriodData(String userId, LocalDate start, LocalDate
LocalDateTime startDateTime = start.atStartOfDay();
LocalDateTime endDateTime = end.atTime(23, 59, 59);
- List analyses = findAnalysisData(userId, startDateTime, endDateTime);
+ List analyses = findEmotionAnalysisData(userId, startDateTime, endDateTime);
if (analyses.isEmpty()) {
throw new RestApiException(_NOT_FOUND);
}
+ // 같은 기간의 치매 분석 데이터 조회
+ List dementiaAnalyses = dementiaAnalysisRepository.findByUserAndPeriod(userId, startDateTime, endDateTime);
+
// 평균 위험 점수 계산
- Double averageRiskScore = analyses.stream()
- .mapToDouble(Analysis::getRiskScore)
+ Double averageRiskScore = dementiaAnalyses.stream()
+ .mapToDouble(DementiaAnalysis::getRiskScore)
.average()
.orElse(0.0);
+ // 매핑 준비: sessionId -> riskScore, date -> 평균 riskScore
+ Map sessionIdToRisk = dementiaAnalyses.stream()
+ .collect(Collectors.toMap(
+ DementiaAnalysis::getSessionId,
+ DementiaAnalysis::getRiskScore,
+ (existing, replacement) -> replacement
+ ));
+
+ Map dateToAverageRisk = dementiaAnalyses.stream()
+ .collect(Collectors.groupingBy(
+ da -> da.getCreatedAt().toLocalDate(),
+ Collectors.averagingDouble(DementiaAnalysis::getRiskScore)
+ ));
+
// 감정 타임라인 생성
- List emotionTimeline = analyses.stream()
- .map(analysis -> new AnalysisResponse.EmotionDataPoint(
- analysis.getCreatedAt().toLocalDate(),
- analysis.getHappy(),
- analysis.getSad(),
- analysis.getAngry(),
- analysis.getSurprised(),
- analysis.getBored(),
- analysis.getRiskScore()
- ))
+ List emotionTimeline = analyses.stream()
+ .map(analysis -> {
+ LocalDate date = analysis.getCreatedAt().toLocalDate();
+
+ Double risk = sessionIdToRisk.get(analysis.getSessionId());
+ if (risk == null) {
+ risk = dateToAverageRisk.get(date);
+ }
+ return new AnalysisResponse.EmotionDataScore(
+ date,
+ analysis.getHappy(),
+ analysis.getSad(),
+ analysis.getAngry(),
+ analysis.getSurprised(),
+ analysis.getBored(),
+ risk
+ );
+ })
.toList();
+ // 기간 내 평균 통화 시간 계산 (Transcript 기반)
+ String averageCallTime = calculateAverageCallTime(userId, startDateTime, endDateTime);
+
return new AnalysisResponse(
userId,
start,
@@ -65,7 +104,7 @@ public AnalysisResponse getPeriodData(String userId, LocalDate start, LocalDate
averageRiskScore,
emotionTimeline,
analyses.size(), // totalParticipate
- "11분 20초" // averageCallTime
+ averageCallTime
);
}
@@ -73,38 +112,49 @@ public AnalysisDayResponse getDayData(String userId, LocalDate date) {
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.atTime(23, 59, 59);
- List dayAnalyses = findAnalysisData(userId, startOfDay, endOfDay);
+ List dayAnalyses = findEmotionAnalysisData(userId, startOfDay, endOfDay);
if (dayAnalyses.isEmpty()) {
- throw new RestApiException(_NOT_FOUND);
+ return new AnalysisDayResponse(
+ userId,
+ date,
+ false,
+ null,
+ null,
+ null,
+ null,
+ null
+ );
}
- Analysis latestAnalysis = dayAnalyses.get(dayAnalyses.size() - 1);
-
- //월간 데이터 생성 (달력용 - 해당 월의 모든 일별 요약)
- List monthlyData = getMonthlyEmotion(userId, date);
+ EmotionAnalysis latestAnalysis = dayAnalyses.get(dayAnalyses.size() - 1);
return new AnalysisDayResponse(
userId,
date,
+ true,
latestAnalysis.getHappy(),
latestAnalysis.getSad(),
latestAnalysis.getAngry(),
latestAnalysis.getSurprised(),
- latestAnalysis.getBored(),
- monthlyData
+ latestAnalysis.getBored()
);
}
- //달력 UI용 월간 감정 요약 데이터 생성
+ // 달력 UI용 월간 감정 요약 데이터 생성 (데이터가 없어도 빈 리스트로 반환)
+ public AnalysisMonthlyEmotionResponse getMonthlyEmotionData(String userId, LocalDate date) {
+ List monthlyData = getMonthlyEmotion(userId, date);
+ LocalDate normalizedMonth = date.withDayOfMonth(1);
+ return new AnalysisMonthlyEmotionResponse(userId, normalizedMonth, monthlyData);
+ }
- private List getMonthlyEmotion(String userId, LocalDate date) {
+ private List getMonthlyEmotion(String userId, LocalDate date) {
// 해당 월의 첫날과 마지막날 계산
LocalDateTime startOfMonth = date.withDayOfMonth(1).atStartOfDay();
LocalDateTime endOfMonth = date.withDayOfMonth(date.lengthOfMonth()).atTime(23, 59, 59);
// 해당 월의 모든 분석 데이터 조회
- List monthlyAnalyses = findAnalysisData(userId, startOfMonth, endOfMonth);
+ List monthlyAnalyses = findEmotionAnalysisData(userId, startOfMonth, endOfMonth);
// 날짜별로 그룹핑하여 각 날의 대표 감정 계산
return monthlyAnalyses.stream()
@@ -114,13 +164,13 @@ private List getMonthlyEmotion(String userId
.entrySet().stream()
.map(entry -> {
LocalDate dailyDate = entry.getKey();
- List dailyAnalyses = entry.getValue();
+ List dailyAnalyses = entry.getValue();
// 해당 날의 마지막 분석 데이터에서 주요 감정 추출
- Analysis lastAnalysisOfDay = dailyAnalyses.get(dailyAnalyses.size() - 1);
+ EmotionAnalysis lastAnalysisOfDay = dailyAnalyses.get(dailyAnalyses.size() - 1);
String mainEmotion = getMainEmotion(lastAnalysisOfDay);
- return new AnalysisDayResponse.EmotionSummary(
+ return new AnalysisMonthlyEmotionResponse.EmotionSummary(
dailyDate,
mainEmotion
);
@@ -135,7 +185,7 @@ public AnalysisReport findLatestReport(String userId, LocalDate periodEnd) {
.orElseThrow(() -> new RestApiException(_NOT_FOUND));
}
- private String getMainEmotion(Analysis analysis) {
+ private String getMainEmotion(EmotionAnalysis analysis) {
double happy = analysis.getHappy();
double sad = analysis.getSad();
double angry = analysis.getAngry();
@@ -150,4 +200,52 @@ private String getMainEmotion(Analysis analysis) {
if (maxScore == surprised) return "surprised";
return "bored";
}
+
+ private String calculateAverageCallTime(String userId, LocalDateTime startDateTime, LocalDateTime endDateTime) {
+ ZoneId zoneId = ZoneId.systemDefault();
+ Instant startInstant = startDateTime.atZone(zoneId).toInstant();
+ Instant endInstant = endDateTime.atZone(zoneId).toInstant();
+
+ List transcripts = transcriptRepository.findByUserAndPeriod(userId, startInstant, endInstant);
+
+ if (transcripts.isEmpty()) {
+ return formatSecondsToKorean(0);
+ }
+
+ List durationsInSeconds = transcripts.stream()
+ .filter(t -> t.getStartTime() != null && t.getEndTime() != null)
+ .filter(t -> !t.getEndTime().isBefore(t.getStartTime()))
+ .map(t -> Duration.between(t.getStartTime(), t.getEndTime()).getSeconds())
+ .filter(seconds -> seconds >= 0)
+ .toList();
+
+ if (durationsInSeconds.isEmpty()) {
+ return formatSecondsToKorean(0);
+ }
+
+ double averageSecondsDouble = durationsInSeconds.stream()
+ .mapToLong(Long::longValue)
+ .average()
+ .orElse(0);
+ long averageSeconds = Math.round(averageSecondsDouble);
+ return formatSecondsToKorean(averageSeconds);
+ }
+
+ private String formatSecondsToKorean(long totalSeconds) {
+ if (totalSeconds <= 0) {
+ return "0초";
+ }
+ long hours = totalSeconds / 3600;
+ long remainder = totalSeconds % 3600;
+ long minutes = remainder / 60;
+ long seconds = remainder % 60;
+
+ if (hours > 0) {
+ return String.format("%d시간 %d분 %d초", hours, minutes, seconds);
+ }
+ if (minutes > 0) {
+ return String.format("%d분 %d초", minutes, seconds);
+ }
+ return String.format("%d초", seconds);
+ }
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/ui/AnalysisController.java b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/ui/AnalysisController.java
index 2676fc1..a4748eb 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/analysis/ui/AnalysisController.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/analysis/ui/AnalysisController.java
@@ -6,11 +6,13 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisResponse;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisDayResponse;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisReportResponse;
+import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisMonthlyEmotionResponse;
import opensource.alzheimerdinger.core.domain.analysis.application.usecase.AnalysisUseCase;
import opensource.alzheimerdinger.core.global.annotation.CurrentUser;
import opensource.alzheimerdinger.core.global.common.BaseResponse;
@@ -23,10 +25,10 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/analysis")
+@SecurityRequirement(name = "Bearer Authentication")
public class AnalysisController {
private final AnalysisUseCase analysisUseCase;
-
//특정 기간 분석 데이터 조회(그래프 활용)
@Operation(
@@ -45,7 +47,7 @@ public class AnalysisController {
)
@GetMapping("/period")
public BaseResponse getAnalysisByPeriod(
- @CurrentUser String userId,
+ @RequestParam String userId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull LocalDate start,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull LocalDate end) {
return BaseResponse.onSuccess(analysisUseCase.getAnalysisPeriodData(userId, start, end));
@@ -55,24 +57,43 @@ public BaseResponse getAnalysisByPeriod(
//일별 감정 분석 데이터 조회 (달력용)
@Operation(
summary = "일별 감정 분석 조회",
- description = "특정 날짜의 감정 분석 데이터와 달력 표시용 월간 데이터를 반환합니다.",
+ description = "특정 날짜의 감정 분석 데이터를 반환합니다. 데이터가 없으면 hasData=false와 빈 값으로 응답합니다.",
parameters = {
@Parameter(name = "date", description = "조회할 날짜 (YYYY-MM-DD)", required = true, example = "2024-01-15")
},
responses = {
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = AnalysisDayResponse.class))),
- @ApiResponse(responseCode = "400", description = "잘못된 날짜 형식", content = @Content),
- @ApiResponse(responseCode = "404", description = "데이터 없음", content = @Content)
+ @ApiResponse(responseCode = "400", description = "잘못된 날짜 형식", content = @Content)
}
)
@GetMapping("/day")
public BaseResponse getDayAnalysis(
- @CurrentUser String userId,
+ @RequestParam String userId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull LocalDate date) {
return BaseResponse.onSuccess(analysisUseCase.getAnalysisDayData(userId, date));
}
+ // 월간 감정 분석 요약 (달력용)
+ @Operation(
+ summary = "월간 감정 요약 조회",
+ description = "달력 표시용 월간 감정 요약 데이터를 반환합니다. 데이터가 없으면 빈 배열을 반환합니다.",
+ parameters = {
+ @Parameter(name = "month", description = "조회 기준 날짜 (해당 달을 의미, YYYY-MM-DD)", required = true, example = "2024-01-15")
+ },
+ responses = {
+ @ApiResponse(responseCode = "200", description = "조회 성공",
+ content = @Content(schema = @Schema(implementation = AnalysisMonthlyEmotionResponse.class))),
+ @ApiResponse(responseCode = "400", description = "잘못된 날짜 형식", content = @Content)
+ }
+ )
+ @GetMapping("/emotion/monthly")
+ public BaseResponse getMonthlyEmotionAnalysis(
+ @RequestParam String userId,
+ @RequestParam("month") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull LocalDate month) {
+ return BaseResponse.onSuccess(analysisUseCase.getAnalysisMonthlyEmotionData(userId, month));
+ }
+
//가장 최근 분석 리포트 조회
@Operation(
@@ -90,7 +111,7 @@ public BaseResponse getDayAnalysis(
)
@GetMapping("/report/latest")
public BaseResponse getLatestReport(
- @CurrentUser String userId,
+ @RequestParam String userId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull LocalDate periodEnd) {
return BaseResponse.onSuccess(analysisUseCase.getLatestReport(userId, periodEnd));
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/feedback/application/usecase/FeedbackUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/feedback/application/usecase/FeedbackUseCase.java
index 320bc55..8088309 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/feedback/application/usecase/FeedbackUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/feedback/application/usecase/FeedbackUseCase.java
@@ -7,6 +7,7 @@
import opensource.alzheimerdinger.core.domain.feedback.domain.service.FeedbackService;
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
@Service
@@ -16,14 +17,10 @@ public class FeedbackUseCase {
private final UserService userService;
private final FeedbackService feedbackService;
- private final MeterRegistry registry;
+ @UseCaseMetric(domain = "feedback", value = "save", type = "command")
public void save(SaveFeedbackRequest request, String userId) {
- registry.counter("domain_feedback_save_requests").increment(); // 호출 횟수
- registry.timer("domain_feedback_save_duration", "domain", "feedback") // 실행 시간
- .record(() -> {
- User user = userService.findUser(userId);
- feedbackService.save(request, user);
- });
+ User user = userService.findUser(userId);
+ feedbackService.save(request, user);
}
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/image/application/usecase/ImageUploadUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/image/application/usecase/ImageUploadUseCase.java
index d2dd0b8..8b69043 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/image/application/usecase/ImageUploadUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/image/application/usecase/ImageUploadUseCase.java
@@ -6,6 +6,7 @@
import opensource.alzheimerdinger.core.domain.user.application.dto.response.ProfileResponse;
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
@Service
@@ -16,6 +17,7 @@ public class ImageUploadUseCase {
private final UserService userService;
/** presigned URL 요청 */
+ @UseCaseMetric(domain = "image", value = "request-upload-url", type = "command")
public UploadUrlResponse requestPostUrl(String userId, String extension) {
User user = userService.findUser(userId);
String uploadUrl = imageService.requestUploadUrl(user, extension);
@@ -23,16 +25,10 @@ public UploadUrlResponse requestPostUrl(String userId, String extension) {
}
/** fileKey 저장 및 ProfileResponse 반환 */
+ @UseCaseMetric(domain = "image", value = "update-profile-image", type = "command")
public ProfileResponse updateImage(String userId, String fileKey) {
User user = userService.findUser(userId);
String imageUrl = imageService.updateProfileImage(user, fileKey);
- var profile = userService.findProfile(userId);
- return new ProfileResponse(
- profile.userId(),
- profile.name(),
- profile.email(),
- profile.gender(),
- imageUrl
- );
+ return ProfileResponse.create(user, imageUrl);
}
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/image/domain/service/ImageService.java b/src/main/java/opensource/alzheimerdinger/core/domain/image/domain/service/ImageService.java
index 8eabec5..14d6718 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/image/domain/service/ImageService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/image/domain/service/ImageService.java
@@ -10,6 +10,8 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.UUID;
@@ -35,12 +37,17 @@ public String requestUploadUrl(User user, String extension) {
return storageService.generateUploadUrl(fileKey);
}
-
/**
* fileKey 저장 및 public URL 반환
*/
@Transactional
public String updateProfileImage(User user, String fileKey) {
+ // 기존 key 확보
+ String oldKey = imageRepo.findByUser(user)
+ .map(ProfileImage::getFileKey)
+ .orElse(null);
+
+ // DB 업데이트 (있으면 교체, 없으면 생성)
imageRepo.findByUser(user).ifPresentOrElse(existing -> {
existing.updateFileKey(fileKey);
}, () -> {
@@ -50,7 +57,19 @@ public String updateProfileImage(User user, String fileKey) {
.build();
imageRepo.save(img);
});
- return storageService.getPublicUrl(fileKey);
+
+ // 커밋 후 이전 Blob 삭제 (최신 1개만 유지)
+ if (oldKey != null && !oldKey.equals(fileKey)) {
+ final String toDelete = oldKey;
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override public void afterCommit() {
+ storageService.deleteObject(toDelete);
+ }
+ });
+ }
+
+ // 보기용 URL 반환 - 서명 URL 24시간
+ return storageService.generateSignedGetUrl(fileKey, 60 * 24);
}
/**
@@ -60,7 +79,7 @@ public String updateProfileImage(User user, String fileKey) {
public String getProfileImageUrl(User user) {
return imageRepo.findByUser(user)
.map(ProfileImage::getFileKey)
- .map(storageService::getPublicUrl)
+ .map(key -> storageService.generateSignedGetUrl(key, 60 * 24))
.orElse(defaultProfileUrl);
}
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/GcsStorageService.java b/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/GcsStorageService.java
index 323db70..3855835 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/GcsStorageService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/GcsStorageService.java
@@ -27,7 +27,20 @@ public String generateUploadUrl(String objectName) {
Storage.SignUrlOption.httpMethod(HttpMethod.PUT),
Storage.SignUrlOption.withV4Signature()
);
- log.debug("[Presigned URL 생성] objectName={}, url={}", objectName, url);
+ log.debug("[Presigned PUT URL] objectName={}, url={}", objectName, url);
+ return url.toString();
+ }
+
+ @Override
+ public String generateSignedGetUrl(String objectName, long minutesToLive) {
+ BlobInfo blobInfo = BlobInfo.newBuilder(bucketName, objectName).build();
+ URL url = storage.signUrl(
+ blobInfo,
+ minutesToLive, TimeUnit.MINUTES,
+ Storage.SignUrlOption.httpMethod(HttpMethod.GET),
+ Storage.SignUrlOption.withV4Signature()
+ );
+ log.debug("[Presigned GET URL] object={}, ttlMin={}, url={}", objectName, minutesToLive, url);
return url.toString();
}
@@ -35,4 +48,20 @@ public String generateUploadUrl(String objectName) {
public String getPublicUrl(String objectName) {
return String.format("https://storage.googleapis.com/%s/%s", bucketName, objectName);
}
+
+ @Override
+ public boolean deleteObject(String objectName) {
+ try {
+ boolean ok = storage.delete(BlobId.of(bucketName, objectName));
+ if (ok) {
+ log.info("[GCS DELETE] deleted object={}", objectName);
+ } else {
+ log.warn("[GCS DELETE] not found object={}", objectName);
+ }
+ return ok;
+ } catch (StorageException e) {
+ log.warn("[GCS DELETE] failed object={} code={} msg={}", objectName, e.getCode(), e.getMessage());
+ return false;
+ }
+ }
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/StorageService.java b/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/StorageService.java
index 2f2d084..b3dbb17 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/StorageService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/image/infra/storage/StorageService.java
@@ -7,8 +7,16 @@ public interface StorageService {
*/
String generateUploadUrl(String objectName);
+ /**
+ * GET 서명 URL
+ */
+ String generateSignedGetUrl(String objectName, long minutesToLive);
+
/**
* public 버킷인 경우 파일에 접근할 수 있는 URL
*/
String getPublicUrl(String objectName);
+
+ // GCS 객체 삭제
+ boolean deleteObject(String objectName);
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/FcmTokenService.java b/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/FcmTokenService.java
index 1096492..2eb154a 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/FcmTokenService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/FcmTokenService.java
@@ -7,6 +7,9 @@
import opensource.alzheimerdinger.core.global.exception.RestApiException;
import org.springframework.stereotype.Service;
+import java.util.Optional;
+
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus.FCM_TOKEN_NOT_FOUND;
import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND;
@Service
@@ -30,8 +33,7 @@ public void expire(String userId) {
fcmTokenRepository.expire(userId);
}
- public String findByUser(User user) {
- return fcmTokenRepository.findTokenByUser(user)
- .orElseThrow(() -> new RestApiException(_NOT_FOUND));
+ public Optional findByUser(User user) {
+ return fcmTokenRepository.findTokenByUser(user);
}
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/NotificationService.java b/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/NotificationService.java
index 08cc93e..dea7138 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/NotificationService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/notification/service/NotificationService.java
@@ -1,9 +1,6 @@
package opensource.alzheimerdinger.core.domain.notification.service;
-import com.google.firebase.messaging.FirebaseMessaging;
-import com.google.firebase.messaging.FirebaseMessagingException;
-import com.google.firebase.messaging.Message;
-import com.google.firebase.messaging.MessagingErrorCode;
+import com.google.firebase.messaging.*;
import lombok.RequiredArgsConstructor;
import opensource.alzheimerdinger.core.domain.notification.entity.Notification;
import opensource.alzheimerdinger.core.domain.notification.repository.NotificationRepository;
@@ -19,14 +16,19 @@ public class NotificationService {
private final NotificationRepository notificationRepository;
public String sendNotification(String token, String title, String body, String userId) {
- com.google.firebase.messaging.Notification fcmNotification = com.google.firebase.messaging.Notification.builder()
- .setTitle(title)
- .setBody(body)
+ WebpushConfig webPush = WebpushConfig.builder()
+ .putHeader("TTL", "86400")
+ .setNotification(new WebpushNotification(
+ title,
+ body,
+ "https://api.alzheimerdinger.com/assets/logo.png"
+ ))
+ .setFcmOptions(WebpushFcmOptions.withLink("https://www.alzheimerdinger.com/call"))
.build();
Message message = Message.builder()
.setToken(token)
- .setNotification(fcmNotification)
+ .setWebpushConfig(webPush)
.build();
User user = userService.findUser(userId);
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/notification/usecase/NotificationUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/notification/usecase/NotificationUseCase.java
index cd5a8ba..7f520ce 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/notification/usecase/NotificationUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/notification/usecase/NotificationUseCase.java
@@ -6,8 +6,11 @@
import opensource.alzheimerdinger.core.domain.relation.domain.entity.Relation;
import opensource.alzheimerdinger.core.domain.relation.domain.entity.RelationStatus;
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
+import java.util.Optional;
+
@Service
@RequiredArgsConstructor
public class NotificationUseCase {
@@ -15,35 +18,54 @@ public class NotificationUseCase {
private final FcmTokenService fcmTokenService;
private final NotificationService notificationService;
+ @UseCaseMetric(domain = "notification", value = "send-reply", type = "command")
public void sendReplyNotification(User user, Relation relation, RelationStatus status) {
User counter = relation.getCounter(user);
String myName = user.getName();
- String counterFcmToken = fcmTokenService.findByUser(counter);
- String myFcmToken = fcmTokenService.findByUser(user);
+ Optional counterFcmToken = fcmTokenService.findByUser(counter);
+ Optional myFcmToken = fcmTokenService.findByUser(user);
+
+ if(counterFcmToken.isEmpty() || myFcmToken.isEmpty())
+ return;
if (RelationStatus.ACCEPTED.equals(status)) {
- notificationService.sendNotification(counterFcmToken, myName + "님이 보호 관계 요청을 수락했어요.", "", counter.getUserId());
- notificationService.sendNotification(myFcmToken, counter.getName() + "님과 이제 보호 관계가 맺어졌어요 ", "", user.getUserId());
+ notificationService.sendNotification(counterFcmToken.get(), myName + "님이 보호 관계 요청을 수락했어요.", "", counter.getUserId());
+ notificationService.sendNotification(myFcmToken.get(), counter.getName() + "님과 이제 보호 관계가 맺어졌어요 ", "", user.getUserId());
}
else if (RelationStatus.REJECTED.equals(status))
- notificationService.sendNotification(counterFcmToken, myName + "님이 보호 관계 요청을 거절했어요.", "", counter.getUserId());
+ notificationService.sendNotification(counterFcmToken.get(), myName + "님이 보호 관계 요청을 거절했어요.", "", counter.getUserId());
}
+ @UseCaseMetric(domain = "notification", value = "send-request", type = "command")
public void sendRequestNotification(User patient, User guardian) {
- String fcmToken = fcmTokenService.findByUser(patient);
- notificationService.sendNotification(fcmToken, guardian.getName() + "님이 보호 관계를 요청했어요.", "", patient.getUserId());
+ Optional fcmToken = fcmTokenService.findByUser(patient);
+
+ if (fcmToken.isEmpty())
+ return;
+
+ notificationService.sendNotification(fcmToken.get(), guardian.getName() + "님이 보호 관계를 요청했어요.", "", patient.getUserId());
}
+ @UseCaseMetric(domain = "notification", value = "send-resend-request", type = "command")
public void sendResendRequestNotification(User patient, Relation relation) {
User guardian = relation.getCounter(patient);
- String fcmToken = fcmTokenService.findByUser(guardian);
- notificationService.sendNotification(fcmToken, patient.getName() + "님이 보호 관계를 재요청했어요.", "", guardian.getUserId());
+ Optional fcmToken = fcmTokenService.findByUser(guardian);
+
+ if (fcmToken.isEmpty())
+ return;
+
+ notificationService.sendNotification(fcmToken.get(), patient.getName() + "님이 보호 관계를 재요청했어요.", "", guardian.getUserId());
}
+ @UseCaseMetric(domain = "notification", value = "send-disconnect", type = "command")
public void sendDisconnectNotification(User user, Relation relation) {
User counter = relation.getCounter(user);
- String fcmToken = fcmTokenService.findByUser(counter);
- notificationService.sendNotification(fcmToken, user.getName() + "님이 보호 관계를 해제하였어요.", "", counter.getUserId());
+ Optional fcmToken = fcmTokenService.findByUser(counter);
+
+ if (fcmToken.isEmpty())
+ return;
+
+ notificationService.sendNotification(fcmToken.get(), user.getName() + "님이 보호 관계를 해제하였어요.", "", counter.getUserId());
}
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationConnectRequest.java b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationConnectRequest.java
index 26bb147..4cbb1dd 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationConnectRequest.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationConnectRequest.java
@@ -1,7 +1,5 @@
package opensource.alzheimerdinger.core.domain.relation.application.dto.request;
-import jakarta.validation.constraints.NotBlank;
-
public record RelationConnectRequest (
- String to
+ String patientCode
) {}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationReconnectRequest.java b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationReconnectRequest.java
index a9f1767..9c1f018 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationReconnectRequest.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/request/RelationReconnectRequest.java
@@ -3,6 +3,5 @@
import jakarta.validation.constraints.NotBlank;
public record RelationReconnectRequest(
- @NotBlank String relationId,
- @NotBlank String to
+ @NotBlank String relationId
) {}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/response/RelationResponse.java b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/response/RelationResponse.java
index b8932ab..e1ec0bb 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/response/RelationResponse.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/dto/response/RelationResponse.java
@@ -6,11 +6,13 @@
import java.time.LocalDateTime;
public record RelationResponse(
- String counterId,
+ String relationId,
+ String userId,
String name,
String patientCode,
Role relationType,
LocalDateTime createdAt,
RelationStatus status,
- Role initiator
+ Boolean isInitiator,
+ String imageUrl
) {}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCase.java
index f3f8bb6..3e7b6ed 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCase.java
@@ -1,8 +1,9 @@
package opensource.alzheimerdinger.core.domain.relation.application.usecase;
-import io.micrometer.core.instrument.MeterRegistry;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import opensource.alzheimerdinger.core.domain.image.domain.service.ImageService;
import opensource.alzheimerdinger.core.domain.notification.usecase.NotificationUseCase;
import opensource.alzheimerdinger.core.domain.relation.application.dto.request.RelationConnectRequest;
import opensource.alzheimerdinger.core.domain.relation.application.dto.request.RelationReconnectRequest;
@@ -10,18 +11,19 @@
import opensource.alzheimerdinger.core.domain.relation.domain.entity.Relation;
import opensource.alzheimerdinger.core.domain.relation.domain.entity.RelationStatus;
import opensource.alzheimerdinger.core.domain.relation.domain.service.RelationService;
-import opensource.alzheimerdinger.core.domain.user.domain.entity.Role;
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
import opensource.alzheimerdinger.core.global.exception.RestApiException;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
+import javax.management.relation.RelationException;
import java.util.List;
import static opensource.alzheimerdinger.core.domain.user.domain.entity.Role.GUARDIAN;
-import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND;
-import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._UNAUTHORIZED;
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus.*;
+@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
@@ -29,77 +31,93 @@ public class RelationManagementUseCase {
private final RelationService relationService;
private final UserService userService;
- private final MeterRegistry registry;
private final NotificationUseCase notificationUseCase;
+ private final ImageService imageService;
+ @UseCaseMetric(domain = "relation", value = "find", type = "query")
public List findRelations(String userId) {
- registry.counter("domain_relation_find_requests").increment(); // 호출 횟수
- return registry.timer("domain_relation_find_duration", "domain", "relation") // 실행 시간
- .record(() -> relationService.findRelations(userId));
+ return relationService.findRelations(userId).stream()
+ .map(relation -> {
+ User user = userService.findUser(relation.userId());
+ String profileImageUrl = imageService.getProfileImageUrl(user);
+
+ return new RelationResponse(
+ relation.relationId(),
+ relation.userId(),
+ relation.name(),
+ relation.patientCode(),
+ relation.relationType(),
+ relation.createdAt(),
+ relation.status(),
+ relation.isInitiator(),
+ profileImageUrl
+ );
+ })
+ .toList();
}
+ @UseCaseMetric(domain = "relation", value = "reply", type = "command")
public void reply(String userId, String relationId, RelationStatus status) {
- registry.counter("domain_relation_reply_requests").increment();
- registry.timer("domain_relation_reply_duration", "domain", "relation")
- .record(() -> {
- Relation relation = relationService.findRelation(relationId);
- User user = userService.findUser(userId);
+ Relation relation = relationService.findRelation(relationId);
+ User user = userService.findUser(userId);
- if (!relation.getRelationStatus().equals(RelationStatus.REQUESTED))
- throw new RestApiException(_NOT_FOUND);
- if (!relation.isReceiver(user))
- throw new RestApiException(_UNAUTHORIZED);
+ if (!RelationStatus.REQUESTED.equals(relation.getRelationStatus()))
+ throw new RestApiException(_NOT_FOUND);
+ if (!relation.isReceiver(user))
+ throw new RestApiException(_UNAUTHORIZED);
- relation.updateStatus(status);
+ relation.updateStatus(status);
- if(RelationStatus.ACCEPTED.equals(status))
- user.updateRole(GUARDIAN);
+ if (RelationStatus.ACCEPTED.equals(status)) {
+ user.updateRole(GUARDIAN);
+ }
- notificationUseCase.sendReplyNotification(user, relation, status);
- });
+ notificationUseCase.sendReplyNotification(user, relation, status);
}
+
+ @UseCaseMetric(domain = "relation", value = "send", type = "command")
public void send(String userId, RelationConnectRequest req) {
- registry.counter("domain_relation_send_requests").increment();
- registry.timer("domain_relation_send_duration", "domain", "relation")
- .record(() -> {
- User guardian = userService.findUser(userId);
- User patient = userService.findUser(req.to());
-
- relationService.save(patient, guardian, RelationStatus.REQUESTED, GUARDIAN);
- notificationUseCase.sendRequestNotification(patient, guardian);
- });
+ User from = userService.findUser(userId);
+ User to = userService.findPatient(req.patientCode());
+
+ if(from.equals(to))
+ throw new RestApiException(INVALID_SELF_RELATION);
+
+ relationService.findRelation(to, from).ifPresent(relation -> {
+ if(RelationStatus.ACCEPTED.equals(relation.getRelationStatus())
+ || RelationStatus.REQUESTED.equals(relation.getRelationStatus()))
+ throw new RestApiException(_EXIST_ENTITY);
+ });
+
+ relationService.upsert(to, from, RelationStatus.REQUESTED);
+ notificationUseCase.sendRequestNotification(to, from);
}
+ @UseCaseMetric(domain = "relation", value = "resend", type = "command")
public void resend(String userId, RelationReconnectRequest req) {
- registry.counter("domain_relation_resend_requests").increment();
- registry.timer("domain_relation_resend_duration", "domain", "relation")
- .record(() -> {
- Relation relation = relationService.findRelation(req.relationId());
- User user = userService.findUser(userId);
-
- if (relation.getRelationStatus() != RelationStatus.DISCONNECTED)
- throw new RestApiException(_NOT_FOUND);
- if (relation.isMember(user))
- throw new RestApiException(_UNAUTHORIZED);
-
- relation.resend(userId);
- notificationUseCase.sendResendRequestNotification(user, relation);
- });
+ Relation relation = relationService.findRelation(req.relationId());
+ User user = userService.findUser(userId);
+
+ if (relation.getRelationStatus() != RelationStatus.DISCONNECTED
+ && relation.getRelationStatus() != RelationStatus.REJECTED)
+ throw new RestApiException(_NOT_FOUND);
+ if (!relation.isMember(user))
+ throw new RestApiException(_UNAUTHORIZED);
+
+ relation.resend();
+ notificationUseCase.sendResendRequestNotification(user, relation);
}
+ @UseCaseMetric(domain = "relation", value = "disconnect", type = "command")
public void disconnect(String userId, String relationId) {
- registry.counter("domain_relation_disconnect_requests").increment();
- registry.timer("domain_relation_disconnect_duration", "domain", "relation")
- .record(() -> {
- Relation relation = relationService.findRelation(relationId);
- User user = userService.findUser(userId);
-
- if (!relation.isMember(user))
- throw new RestApiException(_NOT_FOUND);
-
- relation.updateStatus(RelationStatus.DISCONNECTED);
- notificationUseCase.sendDisconnectNotification(user, relation);
- });
+ Relation relation = relationService.findRelation(relationId);
+ User user = userService.findUser(userId);
+
+ if (!relation.isMember(user))
+ throw new RestApiException(NO_PERMISSION_ON_RELATION);
+
+ relation.updateStatus(RelationStatus.DISCONNECTED);
+ notificationUseCase.sendDisconnectNotification(user, relation);
}
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/entity/Relation.java b/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/entity/Relation.java
index 7bc23c3..252e90f 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/entity/Relation.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/entity/Relation.java
@@ -32,12 +32,11 @@ public class Relation extends BaseEntity {
@Enumerated(EnumType.STRING)
private RelationStatus relationStatus;
- @Enumerated(EnumType.STRING)
- private Role initiator;
+ private String initiator;
public boolean isReceiver(User user) {
- return initiator == Role.PATIENT && guardian.equals(user)
- || initiator == Role.GUARDIAN && patient.equals(user);
+ return this.initiator.equals(this.patient.getUserId()) && guardian.equals(user)
+ || initiator.equals(this.guardian.getUserId()) && patient.equals(user);
}
public void updateStatus(RelationStatus status) {
@@ -52,12 +51,13 @@ public User getCounter(User user) {
return patient.equals(user) ? guardian : patient;
}
- public void resend(String userId) {
+ public void resend() {
this.relationStatus = RelationStatus.REQUESTED;
+ this.initiator = this.patient.getUserId();
+ }
- if(patient.getUserId().equals(userId))
- this.initiator = Role.PATIENT;
- else
- this.initiator = Role.GUARDIAN;
+ public void update(RelationStatus status, String initiator) {
+ this.relationStatus = status;
+ this.initiator = initiator;
}
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/repository/RelationRepository.java b/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/repository/RelationRepository.java
index 34c48a2..6eec047 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/repository/RelationRepository.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/repository/RelationRepository.java
@@ -8,28 +8,36 @@
import org.springframework.data.repository.query.Param;
import java.util.List;
+import java.util.Optional;
public interface RelationRepository extends JpaRepository {
@Query("""
SELECT new opensource.alzheimerdinger.core.domain.relation.application.dto.response.RelationResponse(
- CASE WHEN :userId = patient.userId THEN guardian.userId ELSE patient.userId END,
- CASE WHEN :userId = patient.userId THEN guardian.name ELSE patient.name END,
- CASE WHEN :userId = patient.userId THEN guardian.patientCode ELSE patient.patientCode END,
- CASE WHEN :userId = patient.userId THEN opensource.alzheimerdinger.core.domain.user.domain.entity.Role.GUARDIAN
- ELSE opensource.alzheimerdinger.core.domain.user.domain.entity.Role.PATIENT END,
- relation.createdAt,
- relation.relationStatus,
- relation.initiator
+ relation.relationId,
+ counter.userId,
+ counter.name,
+ counter.patientCode,
+ counter.role,
+ relation.createdAt,
+ relation.relationStatus,
+ (relation.initiator = :userId),
+ pi.fileKey
)
FROM Relation relation
JOIN relation.patient patient
JOIN relation.guardian guardian
- WHERE patient.userId = :userId OR guardian.userId = :userId
+ JOIN User counter
+ ON (counter = patient OR counter = guardian) AND counter.userId <> :userId
+ LEFT JOIN ProfileImage pi
+ ON pi.user = counter
+ WHERE (patient.userId = :userId OR guardian.userId = :userId)
+ AND patient.deletedAt IS NULL
ORDER BY relation.createdAt DESC
""")
List findRelation(@Param("userId") String userId);
+
@Query("""
SELECT COUNT(r) > 0
FROM Relation r
@@ -37,4 +45,13 @@ SELECT COUNT(r) > 0
OR (r.guardian = :u2 AND r.patient = :u1)
""")
boolean existsByUsers(@Param("u1") User u1, @Param("u2") User u2);
+
+ @Query("""
+ SELECT r
+ FROM Relation r
+ WHERE (r.guardian = :u1 AND r.patient = :u2)
+ OR (r.guardian = :u2 AND r.patient = :u1)
+ ORDER BY r.createdAt DESC
+ """)
+ Optional findByPatientAndGuardian(@Param("u1") User u1, @Param("u2") User u2);
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/service/RelationService.java b/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/service/RelationService.java
index 3ede138..5bd6bc2 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/service/RelationService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/relation/domain/service/RelationService.java
@@ -1,6 +1,5 @@
package opensource.alzheimerdinger.core.domain.relation.domain.service;
-import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import opensource.alzheimerdinger.core.domain.relation.application.dto.response.RelationResponse;
import opensource.alzheimerdinger.core.domain.relation.domain.entity.Relation;
@@ -8,13 +7,13 @@
import opensource.alzheimerdinger.core.domain.user.domain.entity.Role;
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.domain.relation.domain.repository.RelationRepository;
-import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
import opensource.alzheimerdinger.core.global.exception.RestApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
+import java.util.Optional;
import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND;
@@ -24,16 +23,24 @@ public class RelationService {
private static final Logger log = LoggerFactory.getLogger(RelationService.class);
private final RelationRepository relationRepository;
- public Relation save(User patient, User guardian, RelationStatus status, Role initiator) {
+ public Relation upsert(User to, User from, RelationStatus status) {
+ String initiator = from.getUserId();
+
log.debug("[RelationService] creating relation: patientId={} guardianId={} initiator={}",
- patient.getUserId(), guardian.getUserId(), initiator);
+ to.getUserId(), from.getUserId(), initiator);
+
+ Relation relation = relationRepository.findByPatientAndGuardian(to, from)
+ .map(r -> {
+ r.update(status, initiator);
+ return r;
+ })
+ .orElseGet(() -> Relation.builder()
+ .patient(to)
+ .guardian(from)
+ .relationStatus(status)
+ .initiator(initiator)
+ .build());
- Relation relation = Relation.builder()
- .patient(patient)
- .guardian(guardian)
- .relationStatus(status)
- .initiator(initiator)
- .build();
Relation saved = relationRepository.save(relation);
log.info("[RelationService] relation created: relationId={}", saved.getRelationId());
@@ -64,4 +71,8 @@ public boolean existsByGuardianAndPatient(User guardian, User patient) {
guardian.getUserId(), patient.getUserId(), exists);
return exists;
}
+
+ public Optional findRelation(User patient, User guardian) {
+ return relationRepository.findByPatientAndGuardian(patient, guardian);
+ }
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderCommandUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderCommandUseCase.java
index 4e20004..ee1a0d7 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderCommandUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderCommandUseCase.java
@@ -5,6 +5,7 @@
import opensource.alzheimerdinger.core.domain.reminder.domain.service.ReminderService;
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -16,6 +17,7 @@ public class ReminderCommandUseCase {
private final ReminderService reminderService;
private final UserService userService;
+ @UseCaseMetric(domain = "reminder", value = "register", type = "command")
public void register(String userId, ReminderSettingRequest request) {
User user = userService.findUser(userId);
reminderService.upsert(user, request);
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderQueryUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderQueryUseCase.java
index 0d870d2..b822e4d 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderQueryUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/reminder/application/usecase/ReminderQueryUseCase.java
@@ -4,6 +4,7 @@
import opensource.alzheimerdinger.core.domain.reminder.application.dto.response.ReminderResponse;
import opensource.alzheimerdinger.core.domain.reminder.domain.entity.Reminder;
import opensource.alzheimerdinger.core.domain.reminder.domain.service.ReminderService;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -14,6 +15,7 @@ public class ReminderQueryUseCase {
private final ReminderService reminderService;
+ @UseCaseMetric(domain = "reminder", value = "find", type = "query")
public ReminderResponse find(String userId) {
Reminder reminder = reminderService.findReminder(userId);
return new ReminderResponse(reminder.getFireTime(), reminder.getStatus());
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/dto/response/TranscriptDetailResponse.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/dto/response/TranscriptDetailResponse.java
new file mode 100644
index 0000000..ced785e
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/dto/response/TranscriptDetailResponse.java
@@ -0,0 +1,23 @@
+package opensource.alzheimerdinger.core.domain.transcript.application.dto.response;
+
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.Speaker;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+
+public record TranscriptDetailResponse(
+ String sessionId,
+ String title,
+ LocalDate date,
+ LocalTime startTime,
+ LocalTime endTime,
+ String durationSeconds,
+ String summary,
+ List conversation
+) {
+ public record Message(
+ Speaker speaker,
+ String content
+ ) {}
+}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/dto/response/TranscriptListResponse.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/dto/response/TranscriptListResponse.java
new file mode 100644
index 0000000..54d2540
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/dto/response/TranscriptListResponse.java
@@ -0,0 +1,13 @@
+package opensource.alzheimerdinger.core.domain.transcript.application.dto.response;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+public record TranscriptListResponse(
+ String sessionId,
+ String title,
+ LocalDate date,
+ LocalTime startTime,
+ LocalTime endTime,
+ String durationSeconds
+) {}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/usecase/TranscriptUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/usecase/TranscriptUseCase.java
new file mode 100644
index 0000000..83c48b3
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/application/usecase/TranscriptUseCase.java
@@ -0,0 +1,29 @@
+package opensource.alzheimerdinger.core.domain.transcript.application.usecase;
+
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptDetailResponse;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptListResponse;
+import opensource.alzheimerdinger.core.domain.transcript.domain.service.TranscriptService;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class TranscriptUseCase {
+
+ private final TranscriptService transcriptService;
+
+ @UseCaseMetric(domain = "transcript", value = "list", type = "query")
+ public List list(String userId) {
+ return transcriptService.getList(userId);
+ }
+
+ @UseCaseMetric(domain = "transcript", value = "detail", type = "query")
+ public TranscriptDetailResponse detail(String userId, String sessionId) {
+ return transcriptService.getDetail(userId, sessionId);
+ }
+}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/Speaker.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/Speaker.java
new file mode 100644
index 0000000..e5a6bba
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/Speaker.java
@@ -0,0 +1,8 @@
+package opensource.alzheimerdinger.core.domain.transcript.domain.entity;
+
+public enum Speaker {
+ patient,
+ ai
+}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/Transcript.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/Transcript.java
new file mode 100644
index 0000000..2cab28a
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/Transcript.java
@@ -0,0 +1,43 @@
+package opensource.alzheimerdinger.core.domain.transcript.domain.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.data.mongodb.core.mapping.Field;
+
+import java.time.Instant;
+import java.util.List;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Document(collection = "transcripts")
+public class Transcript {
+
+ // 세션 ID를 도큐먼트의 기본 키로 사용
+ @Field("session_id")
+ private String sessionId;
+
+ @Indexed
+ @Field("user_id")
+ private String userId;
+
+ @Field("start_time")
+ private Instant startTime;
+
+ @Field("end_time")
+ private Instant endTime;
+
+ private List conversation;
+
+ private String summary;
+
+ private String title;
+}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/TranscriptMessage.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/TranscriptMessage.java
new file mode 100644
index 0000000..6aeabf3
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/entity/TranscriptMessage.java
@@ -0,0 +1,17 @@
+package opensource.alzheimerdinger.core.domain.transcript.domain.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class TranscriptMessage {
+
+ private Speaker speaker;
+
+ private String content;
+}
+
+
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/repository/TranscriptRepository.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/repository/TranscriptRepository.java
new file mode 100644
index 0000000..cccaaf6
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/repository/TranscriptRepository.java
@@ -0,0 +1,24 @@
+package opensource.alzheimerdinger.core.domain.transcript.domain.repository;
+
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.Transcript;
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.data.mongodb.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface TranscriptRepository extends MongoRepository {
+
+ @Query(value = "{ 'user_id': ?0, 'title': { $nin: [null, ''] } }", sort = "{ 'start_time': -1 }")
+ List findByUser(String userId);
+
+ @Query(value = "{ 'user_id': ?0, 'start_time': { $gte: ?1, $lte: ?2 } }", sort = "{ 'start_time': 1 }")
+ List findByUserAndPeriod(String userId, Instant startInclusive, Instant endInclusive);
+
+ Optional findBySessionId(String sessionId);
+}
+
+
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/service/TranscriptService.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/service/TranscriptService.java
new file mode 100644
index 0000000..84f5ff6
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/domain/service/TranscriptService.java
@@ -0,0 +1,81 @@
+package opensource.alzheimerdinger.core.domain.transcript.domain.service;
+
+import lombok.RequiredArgsConstructor;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptDetailResponse;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptListResponse;
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.Transcript;
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.TranscriptMessage;
+import opensource.alzheimerdinger.core.domain.transcript.domain.repository.TranscriptRepository;
+import opensource.alzheimerdinger.core.global.exception.RestApiException;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND;
+
+@Service
+@RequiredArgsConstructor
+public class TranscriptService {
+
+ private final TranscriptRepository transcriptRepository;
+
+ public List getList(String userId) {
+ List transcripts = transcriptRepository.findByUser(userId);
+
+ ZoneId zoneId = ZoneId.systemDefault();
+ return transcripts.stream()
+ .map(t -> new TranscriptListResponse(
+ t.getSessionId(),
+ t.getTitle(),
+ LocalDate.ofInstant(t.getStartTime(), zoneId),
+ LocalTime.ofInstant(t.getStartTime(), zoneId),
+ LocalTime.ofInstant(t.getEndTime(), zoneId),
+ String.valueOf(Duration.between(t.getStartTime(), t.getEndTime()).toSeconds())
+ ))
+ .toList();
+ }
+
+ public TranscriptDetailResponse getDetail(String userId, String sessionId) {
+ Transcript transcript = transcriptRepository.findBySessionId((sessionId))
+ .filter(t -> t.getUserId().equals(userId))
+ .orElseThrow(() -> new RestApiException(_NOT_FOUND));
+
+ ZoneId zoneId = ZoneId.systemDefault();
+
+ List messages = transcript.getConversation() == null
+ ? List.of()
+ : transcript.getConversation().stream()
+ .map(this::mapMessage)
+ .collect(Collectors.toList());
+
+ return new TranscriptDetailResponse(
+ transcript.getSessionId(),
+ transcript.getTitle(),
+ LocalDate.ofInstant(transcript.getStartTime(), zoneId),
+ LocalTime.ofInstant(transcript.getStartTime(), zoneId),
+ LocalTime.ofInstant(transcript.getEndTime(), zoneId),
+ String.valueOf(Duration.between(transcript.getStartTime(), transcript.getEndTime()).toSeconds()),
+ transcript.getSummary(),
+ messages
+ );
+ }
+
+ private TranscriptDetailResponse.Message mapMessage(TranscriptMessage message) {
+ return new TranscriptDetailResponse.Message(
+ message.getSpeaker(),
+ message.getContent()
+ );
+ }
+
+ private String buildTitle(Transcript transcript) {
+ ZoneId zoneId = ZoneId.systemDefault();
+ LocalDate date = LocalDate.ofInstant(transcript.getStartTime(), zoneId);
+ LocalTime time = LocalTime.ofInstant(transcript.getStartTime(), zoneId);
+ return String.format("%s %02d:%02d 통화", date, time.getHour(), time.getMinute());
+ }
+}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/transcript/ui/TranscriptController.java b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/ui/TranscriptController.java
new file mode 100644
index 0000000..f17e106
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/transcript/ui/TranscriptController.java
@@ -0,0 +1,58 @@
+package opensource.alzheimerdinger.core.domain.transcript.ui;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptDetailResponse;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptListResponse;
+import opensource.alzheimerdinger.core.domain.transcript.application.usecase.TranscriptUseCase;
+import opensource.alzheimerdinger.core.global.annotation.CurrentUser;
+import opensource.alzheimerdinger.core.global.common.BaseResponse;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@Tag(name = "Transcript", description = "통화 기록 API")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/transcripts")
+@SecurityRequirement(name = "Bearer Authentication")
+public class TranscriptController {
+
+ private final TranscriptUseCase transcriptUseCase;
+
+ @Operation(
+ summary = "통화 기록 목록 조회",
+ description = "현재 사용자 기준 통화 기록 목록을 반환",
+ responses = @ApiResponse(responseCode = "200", description = "조회 성공",
+ content = @Content(schema = @Schema(implementation = TranscriptListResponse.class)))
+ )
+ @GetMapping
+ public BaseResponse> getTranscripts(
+ @Parameter(hidden = true) @CurrentUser String userId
+ ) {
+ return BaseResponse.onSuccess(transcriptUseCase.list(userId));
+ }
+
+ @Operation(
+ summary = "통화 기록 상세 조회",
+ description = "세션 ID로 통화 기록 상세를 조회",
+ responses = @ApiResponse(responseCode = "200", description = "조회 성공",
+ content = @Content(schema = @Schema(implementation = TranscriptDetailResponse.class)))
+ )
+ @GetMapping("/{sessionId}")
+ public BaseResponse getTranscriptDetail(
+ @Parameter(hidden = true) @CurrentUser String userId,
+ @PathVariable String sessionId
+ ) {
+ return BaseResponse.onSuccess(transcriptUseCase.detail(userId, sessionId));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/dto/request/UpdateProfileRequest.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/dto/request/UpdateProfileRequest.java
new file mode 100644
index 0000000..7f799bb
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/dto/request/UpdateProfileRequest.java
@@ -0,0 +1,19 @@
+package opensource.alzheimerdinger.core.domain.user.application.dto.request;
+
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import opensource.alzheimerdinger.core.domain.user.domain.entity.Gender;
+
+public record UpdateProfileRequest(
+ @NotBlank String name,
+ @NotNull Gender gender,
+ String currentPassword,
+ String newPassword
+) {
+ @AssertTrue(message = "currentPassword is required when newPassword is provided")
+ public boolean isPasswordChangeValid() {
+ if (newPassword == null || newPassword.isBlank()) return true; // 변경 안 함
+ return currentPassword != null && !currentPassword.isBlank(); // 변경 시 현재 비번 필수
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/dto/response/ProfileResponse.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/dto/response/ProfileResponse.java
index 38d369b..7df031f 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/dto/response/ProfileResponse.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/dto/response/ProfileResponse.java
@@ -8,15 +8,18 @@ public record ProfileResponse(
String name,
String email,
Gender gender,
- String imageUrl
+ String imageUrl,
+ String patientCode
) {
- public static ProfileResponse create(User user) {
+ public static ProfileResponse create(User user, String imageUrl) {
return new ProfileResponse(
user.getUserId(),
user.getName(),
user.getEmail(),
user.getGender(),
- null
+ imageUrl,
+ user.getPatientCode()
);
}
+
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UpdateProfileUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UpdateProfileUseCase.java
new file mode 100644
index 0000000..3af889e
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UpdateProfileUseCase.java
@@ -0,0 +1,57 @@
+package opensource.alzheimerdinger.core.domain.user.application.usecase;
+
+import lombok.RequiredArgsConstructor;
+import opensource.alzheimerdinger.core.domain.image.domain.service.ImageService;
+import opensource.alzheimerdinger.core.domain.user.application.dto.request.UpdateProfileRequest;
+import opensource.alzheimerdinger.core.domain.user.application.dto.response.ProfileResponse;
+import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
+import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
+import opensource.alzheimerdinger.core.global.exception.RestApiException;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND;
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._UNAUTHORIZED;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class UpdateProfileUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(UpdateProfileUseCase.class);
+
+ private final UserService userService;
+ private final PasswordEncoder passwordEncoder;
+ private final ImageService imageService;
+
+ @UseCaseMetric(domain = "user-profile", value = "update-profile", type = "command")
+ public ProfileResponse update(String userId, UpdateProfileRequest req) {
+ log.debug("[UpdateProfile] start: userId={}", userId);
+
+ User user = userService.findUser(userId);
+ if (user == null) throw new RestApiException(_NOT_FOUND);
+
+ String encodedNewPassword = null;
+
+ // 새 비번 요청이 있는 경우 → 현재 비번 일치 여부 검사 (불일치 시 권한 오류)
+ if (req.newPassword() != null && !req.newPassword().isBlank()) {
+ boolean matches = passwordEncoder.matches(req.currentPassword(), user.getPassword());
+ if (!matches) {
+ log.warn("[UpdateProfile] password mismatch: userId={}", userId);
+ throw new RestApiException(_UNAUTHORIZED);
+ }
+ encodedNewPassword = passwordEncoder.encode(req.newPassword());
+ }
+
+ user.updateProfile(req.name(), req.gender(), encodedNewPassword);
+
+ String profileImageUrl = imageService.getProfileImageUrl(user);
+
+ log.info("[UpdateProfile] success: userId={}", user.getUserId());
+ return ProfileResponse.create(user, profileImageUrl);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserAuthUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserAuthUseCase.java
index 7cbf9f1..875ec4b 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserAuthUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserAuthUseCase.java
@@ -14,6 +14,7 @@
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.domain.user.domain.service.*;
import opensource.alzheimerdinger.core.global.exception.RestApiException;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import opensource.alzheimerdinger.core.global.security.TokenProvider;
import opensource.alzheimerdinger.core.global.util.SecureRandomGenerator;
import org.slf4j.Logger;
@@ -45,6 +46,7 @@ public class UserAuthUseCase {
private static final Logger log = LoggerFactory.getLogger(UserAuthUseCase.class);
private final NotificationUseCase notificationUseCase;
+ @UseCaseMetric(domain = "user-auth", value = "sign-up", type = "command")
public void signUp(SignUpRequest request) {
log.debug("[UserAuth] signUp start: email={}", request.email());
// 이미 가입된 이메일인지 확인
@@ -66,7 +68,7 @@ public void signUp(SignUpRequest request) {
throw new RestApiException(_NOT_FOUND);
}
- relationService.save(patient, user, RelationStatus.REQUESTED, Role.GUARDIAN);
+ relationService.upsert(patient, user, RelationStatus.REQUESTED);
notificationUseCase.sendRequestNotification(patient, user);
log.debug("[UserAuth] relation created: guardianId={}, patientId={}", user.getUserId(), patient.getUserId());
}
@@ -74,6 +76,7 @@ public void signUp(SignUpRequest request) {
log.info("[UserAuth] signUp success: userId={}", user.getUserId());
}
+ @UseCaseMetric(domain = "user-auth", value = "login", type = "command")
public LoginResponse login(LoginRequest request) {
log.debug("[UserAuth] login start: email={}", request.email());
// 이메일로 유저 객체 조회
@@ -103,6 +106,7 @@ public LoginResponse login(LoginRequest request) {
return new LoginResponse(accessToken, refreshToken);
}
+ @UseCaseMetric(domain = "user-auth", value = "logout", type = "command")
public void logout(HttpServletRequest request) {
log.debug("[UserAuth] logout start");
// 회원 정보 조회
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserProfileUseCase.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserProfileUseCase.java
index e0789a3..11b4869 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserProfileUseCase.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UserProfileUseCase.java
@@ -5,6 +5,7 @@
import opensource.alzheimerdinger.core.domain.user.application.dto.response.ProfileResponse;
import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
+import opensource.alzheimerdinger.core.global.metric.UseCaseMetric;
import org.springframework.stereotype.Service;
@Service
@@ -14,6 +15,7 @@ public class UserProfileUseCase {
private final UserService userService;
private final ImageService imageService;
+ @UseCaseMetric(domain = "user-profile", value = "find-profile", type = "query")
public ProfileResponse findProfile(String userId) {
ProfileResponse profileDto = userService.findProfile(userId);
User user = userService.findUser(userId);
@@ -24,7 +26,8 @@ public ProfileResponse findProfile(String userId) {
profileDto.name(),
profileDto.email(),
profileDto.gender(),
- imageUrl
+ imageUrl,
+ profileDto.patientCode()
);
}
}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/entity/User.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/entity/User.java
index f2f0187..6b9c7b2 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/entity/User.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/entity/User.java
@@ -8,6 +8,8 @@
import lombok.NoArgsConstructor;
import opensource.alzheimerdinger.core.global.common.BaseEntity;
+import java.util.Objects;
+
@Entity
@Getter
@Table(name = "users")
@@ -39,4 +41,12 @@ public class User extends BaseEntity {
public void updateRole(Role role) {
this.role = role;
}
+
+ public void updateProfile(String name, Gender gender, String encodedNewPassword) {
+ this.name = name;
+ this.gender = gender;
+ if (encodedNewPassword != null && !encodedNewPassword.isBlank()) {
+ this.password = encodedNewPassword;
+ }
+ }
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/service/UserService.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/service/UserService.java
index 76b1e16..f74fcee 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/service/UserService.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/domain/service/UserService.java
@@ -1,6 +1,7 @@
package opensource.alzheimerdinger.core.domain.user.domain.service;
import lombok.RequiredArgsConstructor;
+import opensource.alzheimerdinger.core.domain.image.domain.service.ImageService;
import opensource.alzheimerdinger.core.domain.user.application.dto.request.SignUpToGuardianRequest;
import opensource.alzheimerdinger.core.domain.user.application.dto.request.SignUpRequest;
import opensource.alzheimerdinger.core.domain.user.application.dto.response.ProfileResponse;
@@ -13,7 +14,7 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
-import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND;
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus.*;
@Service
@RequiredArgsConstructor
@@ -22,6 +23,7 @@ public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
+ private final ImageService imageService;
public User findByEmail(String email) {
log.debug("[UserService] findByEmail: email={}", email);
@@ -62,7 +64,7 @@ public User findPatient(String code) {
return userRepository.findByPatientCode(code)
.orElseThrow(() -> {
log.warn("[UserService] patient not found by code={}", code);
- return new RestApiException(_NOT_FOUND);
+ return new RestApiException(PATIENT_NOT_FOUND);
});
}
@@ -71,7 +73,7 @@ public User findUser(String userId) {
return userRepository.findById(userId)
.orElseThrow(() -> {
log.warn("[UserService] user not found by userId={}", userId);
- return new RestApiException(_NOT_FOUND);
+ return new RestApiException(USER_NOT_FOUND);
});
}
@@ -79,7 +81,10 @@ public User findUser(String userId) {
public ProfileResponse findProfile(String userId) {
log.debug("[UserService] findProfile for userId={}", userId);
ProfileResponse profile = userRepository.findById(userId)
- .map(ProfileResponse::create)
+ .map(user -> {
+ String profileImageUrl = imageService.getProfileImageUrl(user);
+ return ProfileResponse.create(user, profileImageUrl);
+ })
.orElseThrow(() -> {
log.warn("[UserService] profile not found for userId={}", userId);
return new RestApiException(_NOT_FOUND);
diff --git a/src/main/java/opensource/alzheimerdinger/core/domain/user/ui/UserController.java b/src/main/java/opensource/alzheimerdinger/core/domain/user/ui/UserController.java
index efa6eb0..5036270 100644
--- a/src/main/java/opensource/alzheimerdinger/core/domain/user/ui/UserController.java
+++ b/src/main/java/opensource/alzheimerdinger/core/domain/user/ui/UserController.java
@@ -7,14 +7,15 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import opensource.alzheimerdinger.core.domain.user.application.dto.request.UpdateProfileRequest;
import opensource.alzheimerdinger.core.domain.user.application.dto.response.ProfileResponse;
+import opensource.alzheimerdinger.core.domain.user.application.usecase.UpdateProfileUseCase;
import opensource.alzheimerdinger.core.domain.user.application.usecase.UserProfileUseCase;
import opensource.alzheimerdinger.core.global.annotation.CurrentUser;
import opensource.alzheimerdinger.core.global.common.BaseResponse;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
@Tag(name = "User", description = "사용자 프로필 API")
@RestController
@@ -23,7 +24,8 @@
@SecurityRequirement(name = "Bearer Authentication")
public class UserController {
- private final UserProfileUseCase userProfileUseCase;
+ private final UserProfileUseCase userProfileUseCase; // 조회
+ private final UpdateProfileUseCase updateProfileUseCase; // 수정
@Operation(
summary = "프로필 조회",
@@ -36,4 +38,23 @@ public BaseResponse getProfile(
@Parameter(hidden = true) @CurrentUser String userId) {
return BaseResponse.onSuccess(userProfileUseCase.findProfile(userId));
}
+
+ @Operation(
+ summary = "프로필 수정",
+ description = """
+ 이름/성별/비밀번호 변경을 지원합니다.
+ - 환자번호(patientCode)는 수정 불가
+ - 비밀번호 변경 시: currentPassword 검증 후 newPassword 저장(BCrypt 해싱)
+ """,
+ responses = @ApiResponse(responseCode = "200", description = "수정 성공",
+ content = @Content(schema = @Schema(implementation = ProfileResponse.class)))
+ )
+ @PatchMapping("/profile")
+ public BaseResponse updateProfile(
+ @Parameter(hidden = true) @CurrentUser String userId,
+ @Valid @RequestBody UpdateProfileRequest request
+ ) {
+ ProfileResponse updated = updateProfileUseCase.update(userId, request);
+ return BaseResponse.onSuccess(updated);
+ }
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/global/config/FcmConfig.java b/src/main/java/opensource/alzheimerdinger/core/global/config/FcmConfig.java
index 4432bc9..d561918 100644
--- a/src/main/java/opensource/alzheimerdinger/core/global/config/FcmConfig.java
+++ b/src/main/java/opensource/alzheimerdinger/core/global/config/FcmConfig.java
@@ -13,6 +13,8 @@
import java.io.FileInputStream;
import java.io.IOException;
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus.FIREBASE_DISCONNECTED;
+
@Component
@RequiredArgsConstructor
public class FcmConfig {
@@ -33,7 +35,7 @@ public void init() {
FirebaseApp.initializeApp(options);
}
} catch (IOException e) {
- throw new RestApiException(null);
+ throw new RestApiException(FIREBASE_DISCONNECTED);
}
}
}
diff --git a/src/main/java/opensource/alzheimerdinger/core/global/config/SecurityConfig.java b/src/main/java/opensource/alzheimerdinger/core/global/config/SecurityConfig.java
index 18d5e87..34b96da 100644
--- a/src/main/java/opensource/alzheimerdinger/core/global/config/SecurityConfig.java
+++ b/src/main/java/opensource/alzheimerdinger/core/global/config/SecurityConfig.java
@@ -101,6 +101,12 @@ public CorsConfigurationSource corsConfigurationSource() {
config.setAllowCredentials(true);
// pre-flight 캐시 시간 (초)
config.setMaxAge(corsProperties.getMaxAge());
+
+ // 필요한 응답 헤더만 노출 (보안상 안전)
+ config.addExposedHeader("Authorization");
+ config.addExposedHeader("Content-Type");
+ config.addExposedHeader("X-Total-Count");
+ config.addExposedHeader("Access-Control-Allow-Origin");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 모든 경로에 대해 위 정책을 적용
diff --git a/src/main/java/opensource/alzheimerdinger/core/global/exception/code/status/GlobalErrorStatus.java b/src/main/java/opensource/alzheimerdinger/core/global/exception/code/status/GlobalErrorStatus.java
index 7d73f47..78e0894 100644
--- a/src/main/java/opensource/alzheimerdinger/core/global/exception/code/status/GlobalErrorStatus.java
+++ b/src/main/java/opensource/alzheimerdinger/core/global/exception/code/status/GlobalErrorStatus.java
@@ -29,6 +29,12 @@ public enum GlobalErrorStatus implements BaseCodeInterface {
FIREBASE_DISCONNECTED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM002","알림 토큰에 문제가 발생하였습니다."),
+ NO_PERMISSION_ON_RELATION(HttpStatus.UNAUTHORIZED, "ROOM005", "요청에 접근 권한이 없습니다."),
+ USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER404", "요청한 정보를 찾을 수 없습니다."),
+ PATIENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PATIENT404", "요청한 정보를 찾을 수 없습니다."),
+ FCM_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "FCM404", "요청한 정보를 찾을 수 없습니다."),
+ INVALID_SELF_RELATION(HttpStatus.BAD_REQUEST, "RELATION001", "자신에게 요청을 보낼 수 없습니다."),
+
// For test
TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "예외처리 테스트입니다."),
;
diff --git a/src/main/java/opensource/alzheimerdinger/core/global/metric/UseCaseMetric.java b/src/main/java/opensource/alzheimerdinger/core/global/metric/UseCaseMetric.java
new file mode 100644
index 0000000..8a9ee0a
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/global/metric/UseCaseMetric.java
@@ -0,0 +1,13 @@
+package opensource.alzheimerdinger.core.global.metric;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface UseCaseMetric {
+ String domain(); // UseCase 도메인
+ String value(); // ex) get-period, save, login...
+ String type() default "command"; // query or command
+ String group() default "ad.usecase"; // metric group prefix
+}
\ No newline at end of file
diff --git a/src/main/java/opensource/alzheimerdinger/core/global/metric/UseCaseMetricAspect.java b/src/main/java/opensource/alzheimerdinger/core/global/metric/UseCaseMetricAspect.java
new file mode 100644
index 0000000..2f0751e
--- /dev/null
+++ b/src/main/java/opensource/alzheimerdinger/core/global/metric/UseCaseMetricAspect.java
@@ -0,0 +1,58 @@
+package opensource.alzheimerdinger.core.global.metric;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+@Aspect
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class UseCaseMetricAspect {
+
+ private final MeterRegistry registry;
+
+ @Around(value = "@within(anno) || @annotation(anno)", argNames = "pjp,anno")
+ public Object around(ProceedingJoinPoint pjp, UseCaseMetric anno) throws Throwable {
+ final String group = anno.group(); // ad.usecase
+ final String domain = anno.domain(); // e.g. analysis
+ final String usecase = anno.value(); // e.g. get-period
+ final String type = anno.type(); // query | command
+
+ log.debug("UseCaseMetric AOP hit: domain={}, usecase={}, type={}", anno.domain(), anno.value(), anno.type());
+
+ // 공통 타이머 (히스토그램 활성화는 yml에서)
+ Timer.Builder durationBuilder = Timer.builder(group + ".duration")
+ .tag("domain", domain)
+ .tag("usecase", usecase)
+ .tag("type", type);
+
+ // 호출 카운터 (outcome tag 로 성공/실패 구분)
+ Counter.Builder callsBuilder = Counter.builder(group + ".calls")
+ .tag("domain", domain)
+ .tag("usecase", usecase)
+ .tag("type", type);
+
+ Timer.Sample sample = Timer.start(registry);
+ try {
+ Object result = pjp.proceed();
+ sample.stop(durationBuilder.tag("outcome", "success").register(registry));
+ callsBuilder.tag("outcome", "success").register(registry).increment();
+ return result;
+ } catch (Throwable t) {
+ // 실패도 같은 타이머에 outcome=fail 로 기록
+ sample.stop(durationBuilder
+ .tag("outcome", "fail")
+ .tag("exception", t.getClass().getSimpleName()) // 클래스명 정도는 저카디널리티
+ .register(registry));
+ callsBuilder.tag("outcome", "fail").register(registry).increment();
+ throw t;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 1b77b7e..fa19978 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -60,10 +60,13 @@ fcm:
prefix: /app/secret/
springdoc:
+ api-docs:
+ enabled: true
swagger-ui:
groups-order: DESC # path, query, body, response 순서대로 출력
tags-sorter: alpha # 태그를 알파벳 순으로 정렬
operations-sorter: method # delete - get - patch - post - put 순으로 정렬
+ enabled: true
paths-to-match:
- /api/**
@@ -129,13 +132,13 @@ logging:
org.springframework.security: DEBUG
org.apache.kafka: WARN
org.springframework.kafka: INFO
- org.springframework.batch.core: DEBUG
- org.springframework.jdbc.core: DEBUG
+ org.springframework.aop: DEBUG
+ opensource.alzheimerdinger.core.global.metric: DEBUG
cors:
- allowed-origins: "http://localhost:5173"
+ allowed-origins: "http://localhost:5173,http://localhost:8080,https://api.alzheimerdinger.com,https://alzheimerdinger.com,https://www.alzheimerdinger.com"
allowed-methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
- allowed-headers: "Authorization,Content-Type,Accept"
+ allowed-headers: "Authorization,Content-Type,Accept,X-Requested-With,DNT,User-Agent,If-Modified-Since,Cache-Control,Range"
max-age: 3600
management:
@@ -146,7 +149,17 @@ management:
web:
exposure:
include: health,info,prometheus
-
+ metrics:
+ tags:
+ app: alzheimerdinger-core
+ env: ${APP_ENV:local}
+ distribution:
+ percentiles-histogram:
+ ad.usecase.duration: true # AOP 타이머에 히스토그램 활성화 (p95/p99)
+ percentiles:
+ ad.usecase.duration: 0.5,0.9,0.95,0.99
+ slo:
+ http.server.requests: 50ms,100ms,200ms,500ms,1s,2s
prometheus:
metrics:
export:
diff --git a/src/main/resources/assets/logo.png b/src/main/resources/assets/logo.png
new file mode 100644
index 0000000..10d42d4
Binary files /dev/null and b/src/main/resources/assets/logo.png differ
diff --git a/src/test/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCaseTest.java b/src/test/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCaseTest.java
index d216a90..cb0835d 100644
--- a/src/test/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCaseTest.java
+++ b/src/test/java/opensource/alzheimerdinger/core/domain/analysis/application/usecase/AnalysisUseCaseTest.java
@@ -81,7 +81,7 @@ void getAnalysisDayData_success() {
LocalDate date = LocalDate.of(2024, 1, 25);
AnalysisDayResponse expectedResponse = new AnalysisDayResponse(
- userId, date, 0.8, 0.1, 0.05, 0.03, 0.02, List.of()
+ userId, date, true, 0.8, 0.1, 0.05, 0.03, 0.02
);
when(analysisService.getDayData(userId, date)).thenReturn(expectedResponse);
@@ -97,22 +97,22 @@ void getAnalysisDayData_success() {
}
@Test
- void getAnalysisData_fail_no_Day_data() {
+ void getAnalysisDayData_noData_returns_hasDataFalse() {
// Given
String userId = "user123";
LocalDate date = LocalDate.of(2024, 1, 25);
- when(analysisService.getDayData(userId, date))
- .thenThrow(new RestApiException(_NOT_FOUND));
+ AnalysisDayResponse noDataResponse = new AnalysisDayResponse(
+ userId, date, false, null, null, null, null, null
+ );
+ when(analysisService.getDayData(userId, date)).thenReturn(noDataResponse);
// When
- Throwable thrown = catchThrowable(() -> analysisUseCase.getAnalysisDayData(userId, date));
+ AnalysisDayResponse result = analysisUseCase.getAnalysisDayData(userId, date);
// Then
- assertThat(thrown)
- .isInstanceOf(RestApiException.class);
- assertThat(((RestApiException) thrown).getErrorCode())
- .isEqualTo(_NOT_FOUND.getCode());
+ assertThat(result.hasData()).isFalse();
+ assertThat(result.happyScore()).isNull();
}
@Test
@@ -124,7 +124,7 @@ void getLatestReport_success() {
AnalysisReport mockReport = mock(AnalysisReport.class);
when(mockReport.getAnalysisReportId()).thenReturn("report123");
when(mockReport.getCreatedAt()).thenReturn(LocalDateTime.of(2024, 1, 30, 15, 0));
- when(mockReport.getReport()).thenReturn("1월 종합 분석 결과입니다.");
+ when(mockReport.getContent()).thenReturn("1월 종합 분석 결과입니다.");
when(analysisService.findLatestReport(userId, periodEnd)).thenReturn(mockReport);
@@ -167,7 +167,7 @@ void getLatestReport_with_valid_data_structure() {
AnalysisReport mockReport = mock(AnalysisReport.class);
when(mockReport.getAnalysisReportId()).thenReturn("report123");
when(mockReport.getCreatedAt()).thenReturn(createdAt);
- when(mockReport.getReport()).thenReturn("상세 분석 리포트 내용");
+ when(mockReport.getContent()).thenReturn("상세 분석 리포트 내용");
when(analysisService.findLatestReport(userId, periodEnd)).thenReturn(mockReport);
diff --git a/src/test/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisServiceTest.java b/src/test/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisServiceTest.java
index c8c8894..e46dfc8 100644
--- a/src/test/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisServiceTest.java
+++ b/src/test/java/opensource/alzheimerdinger/core/domain/analysis/domain/service/AnalysisServiceTest.java
@@ -2,10 +2,13 @@
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisResponse;
import opensource.alzheimerdinger.core.domain.analysis.application.dto.response.AnalysisDayResponse;
-import opensource.alzheimerdinger.core.domain.analysis.domain.entity.Analysis;
+import opensource.alzheimerdinger.core.domain.analysis.domain.entity.EmotionAnalysis;
import opensource.alzheimerdinger.core.domain.analysis.domain.entity.AnalysisReport;
-import opensource.alzheimerdinger.core.domain.analysis.domain.repository.AnalysisRepository;
+import opensource.alzheimerdinger.core.domain.analysis.domain.repository.EmotionAnalysisRepository;
+import opensource.alzheimerdinger.core.domain.analysis.domain.repository.DementiaAnalysisRepository;
import opensource.alzheimerdinger.core.domain.analysis.domain.repository.AnalysisReportRepository;
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.Transcript;
+import opensource.alzheimerdinger.core.domain.transcript.domain.repository.TranscriptRepository;
import opensource.alzheimerdinger.core.global.exception.RestApiException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -13,6 +16,7 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.List;
@@ -22,18 +26,25 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable;
import static org.mockito.Mockito.*;
-import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class AnalysisServiceTest {
@Mock
- AnalysisRepository analysisRepository;
+ EmotionAnalysisRepository emotionAnalysisRepository;
+
+ @Mock
+ DementiaAnalysisRepository dementiaAnalysisRepository;
@Mock
AnalysisReportRepository analysisReportRepository;
+ @Mock
+ TranscriptRepository transcriptRepository;
+
@InjectMocks
AnalysisService analysisService;
@@ -44,19 +55,19 @@ void findAnalysisData_success() {
LocalDateTime start = LocalDateTime.of(2024, 1, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2024, 1, 31, 23, 59);
- Analysis analysis1 = mock(Analysis.class);
- Analysis analysis2 = mock(Analysis.class);
- List expectedAnalyses = List.of(analysis1, analysis2);
+ EmotionAnalysis a1 = mock(EmotionAnalysis.class);
+ EmotionAnalysis a2 = mock(EmotionAnalysis.class);
+ List expected = List.of(a1, a2);
- when(analysisRepository.findByUserAndPeriod(userId, start, end)).thenReturn(expectedAnalyses);
+ when(emotionAnalysisRepository.findByUserAndPeriod(userId, start, end)).thenReturn(expected);
// When
- List result = analysisService.findAnalysisData(userId, start, end);
+ List result = analysisService.findEmotionAnalysisData(userId, start, end);
// Then
assertThat(result).hasSize(2);
- assertThat(result).containsExactly(analysis1, analysis2);
- verify(analysisRepository).findByUserAndPeriod(userId, start, end);
+ assertThat(result).containsExactly(a1, a2);
+ verify(emotionAnalysisRepository).findByUserAndPeriod(userId, start, end);
}
@Test
@@ -66,27 +77,55 @@ void getPeriodData_success() {
LocalDate start = LocalDate.of(2024, 1, 1);
LocalDate end = LocalDate.of(2024, 1, 31);
- Analysis analysis1 = mock(Analysis.class);
- when(analysis1.getRiskScore()).thenReturn(0.2);
- when(analysis1.getCreatedAt()).thenReturn(LocalDateTime.now());
- when(analysis1.getHappy()).thenReturn(0.8);
- when(analysis1.getSad()).thenReturn(0.1);
- when(analysis1.getAngry()).thenReturn(0.05);
- when(analysis1.getSurprised()).thenReturn(0.03);
- when(analysis1.getBored()).thenReturn(0.02);
-
- Analysis analysis2 = mock(Analysis.class);
- when(analysis2.getRiskScore()).thenReturn(0.4);
- when(analysis2.getCreatedAt()).thenReturn(LocalDateTime.now());
- when(analysis2.getHappy()).thenReturn(0.6);
- when(analysis2.getSad()).thenReturn(0.3);
- when(analysis2.getAngry()).thenReturn(0.06);
- when(analysis2.getSurprised()).thenReturn(0.02);
- when(analysis2.getBored()).thenReturn(0.02);
-
- List analyses = List.of(analysis1, analysis2);
-
- when(analysisRepository.findByUserAndPeriod(eq(userId), any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(analyses);
+ EmotionAnalysis e1 = mock(EmotionAnalysis.class);
+ when(e1.getCreatedAt()).thenReturn(LocalDateTime.now());
+ when(e1.getHappy()).thenReturn(0.8);
+ when(e1.getSad()).thenReturn(0.1);
+ when(e1.getAngry()).thenReturn(0.05);
+ when(e1.getSurprised()).thenReturn(0.03);
+ when(e1.getBored()).thenReturn(0.02);
+ when(e1.getSessionId()).thenReturn("s-1");
+
+ EmotionAnalysis e2 = mock(EmotionAnalysis.class);
+ when(e2.getCreatedAt()).thenReturn(LocalDateTime.now());
+ when(e2.getHappy()).thenReturn(0.6);
+ when(e2.getSad()).thenReturn(0.3);
+ when(e2.getAngry()).thenReturn(0.06);
+ when(e2.getSurprised()).thenReturn(0.02);
+ when(e2.getBored()).thenReturn(0.02);
+ when(e2.getSessionId()).thenReturn("s-2");
+
+ when(emotionAnalysisRepository.findByUserAndPeriod(eq(userId), any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(List.of(e1, e2));
+
+ // 위험도 평균을 위해 치매 분석은 간단히 0.2, 0.4로 가정
+ var d1 = mock(opensource.alzheimerdinger.core.domain.analysis.domain.entity.DementiaAnalysis.class);
+ when(d1.getRiskScore()).thenReturn(0.2);
+ when(d1.getSessionId()).thenReturn("s-1");
+ when(d1.getCreatedAt()).thenReturn(LocalDateTime.now());
+ var d2 = mock(opensource.alzheimerdinger.core.domain.analysis.domain.entity.DementiaAnalysis.class);
+ when(d2.getRiskScore()).thenReturn(0.4);
+ when(d2.getSessionId()).thenReturn("s-9");
+ when(d2.getCreatedAt()).thenReturn(LocalDateTime.now());
+
+ when(dementiaAnalysisRepository.findByUserAndPeriod(eq(userId), any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(List.of(d1, d2));
+
+ // Transcript 평균 통화 시간 계산을 위한 목 데이터
+ Transcript t1 = Transcript.builder()
+ .sessionId("s-1")
+ .userId(userId)
+ .startTime(Instant.parse("2024-01-01T00:00:00Z"))
+ .endTime(Instant.parse("2024-01-01T00:02:00Z")) // 120초
+ .build();
+ Transcript t2 = Transcript.builder()
+ .sessionId("s-2")
+ .userId(userId)
+ .startTime(Instant.parse("2024-01-02T00:00:00Z"))
+ .endTime(Instant.parse("2024-01-02T00:01:00Z")) // 60초
+ .build();
+ when(transcriptRepository.findByUserAndPeriod(eq(userId), any(Instant.class), any(Instant.class)))
+ .thenReturn(List.of(t1, t2));
// When
AnalysisResponse result = analysisService.getPeriodData(userId, start, end);
@@ -95,7 +134,7 @@ void getPeriodData_success() {
assertThat(result.userId()).isEqualTo(userId);
assertThat(result.start()).isEqualTo(start);
assertThat(result.end()).isEqualTo(end);
- assertThat(result.averageRiskScore()).isEqualTo(0.3); // (0.2 + 0.4) / 2
+ assertThat(result.averageRiskScore()).isCloseTo(0.3, org.assertj.core.api.Assertions.within(1e-6)); // (0.2 + 0.4) / 2
assertThat(result.emotionTimeline()).hasSize(2);
assertThat(result.totalParticipate()).isEqualTo(2);
}
@@ -107,7 +146,8 @@ void getPeriodData_fail_no_data() {
LocalDate start = LocalDate.of(2024, 1, 1);
LocalDate end = LocalDate.of(2024, 1, 31);
- when(analysisRepository.findByUserAndPeriod(eq(userId), any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(List.of());
+ when(emotionAnalysisRepository.findByUserAndPeriod(eq(userId), any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(List.of());
// When
Throwable thrown = catchThrowable(() -> analysisService.getPeriodData(userId, start, end));
@@ -125,26 +165,26 @@ void getDayData_success() {
String userId = "user123";
LocalDate date = LocalDate.of(2024, 1, 25);
- Analysis analysis1 = mock(Analysis.class);
- lenient().when(analysis1.getHappy()).thenReturn(0.7);
- lenient().when(analysis1.getSad()).thenReturn(0.2);
- lenient().when(analysis1.getAngry()).thenReturn(0.05);
- lenient().when(analysis1.getSurprised()).thenReturn(0.03);
- lenient().when(analysis1.getBored()).thenReturn(0.02);
- lenient().when(analysis1.getCreatedAt()).thenReturn(LocalDateTime.of(2024, 1, 25, 10, 0));
+ EmotionAnalysis e1 = mock(EmotionAnalysis.class);
+ lenient().when(e1.getHappy()).thenReturn(0.7);
+ lenient().when(e1.getSad()).thenReturn(0.2);
+ lenient().when(e1.getAngry()).thenReturn(0.05);
+ lenient().when(e1.getSurprised()).thenReturn(0.03);
+ lenient().when(e1.getBored()).thenReturn(0.02);
+ lenient().when(e1.getCreatedAt()).thenReturn(LocalDateTime.of(2024, 1, 25, 10, 0));
- Analysis analysis2 = mock(Analysis.class);
- lenient().when(analysis2.getHappy()).thenReturn(0.8);
- lenient().when(analysis2.getSad()).thenReturn(0.1);
- lenient().when(analysis2.getAngry()).thenReturn(0.05);
- lenient().when(analysis2.getSurprised()).thenReturn(0.03);
- lenient().when(analysis2.getBored()).thenReturn(0.02);
- lenient().when(analysis2.getCreatedAt()).thenReturn(LocalDateTime.of(2024, 1, 25, 15, 0));
+ EmotionAnalysis e2 = mock(EmotionAnalysis.class);
+ lenient().when(e2.getHappy()).thenReturn(0.8);
+ lenient().when(e2.getSad()).thenReturn(0.1);
+ lenient().when(e2.getAngry()).thenReturn(0.05);
+ lenient().when(e2.getSurprised()).thenReturn(0.03);
+ lenient().when(e2.getBored()).thenReturn(0.02);
+ lenient().when(e2.getCreatedAt()).thenReturn(LocalDateTime.of(2024, 1, 25, 15, 0));
- List dayAnalyses = List.of(analysis1, analysis2);
+ List dayAnalyses = List.of(e1, e2);
// 모든 findByUserAndPeriod 호출에 대해 같은 결과 반환
- lenient().when(analysisRepository.findByUserAndPeriod(eq(userId), any(LocalDateTime.class), any(LocalDateTime.class)))
+ lenient().when(emotionAnalysisRepository.findByUserAndPeriod(eq(userId), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(dayAnalyses);
// When
@@ -153,10 +193,9 @@ void getDayData_success() {
// Then
assertThat(result.userId()).isEqualTo(userId);
assertThat(result.analysisDate()).isEqualTo(date);
- // 마지막 분석 데이터의 감정 점수들 (analysis2)
assertThat(result.happyScore()).isEqualTo(0.8);
assertThat(result.sadScore()).isEqualTo(0.1);
- assertThat(result.monthlyEmotionData()).isNotNull();
+ assertThat(result.hasData()).isTrue();
}
@Test
@@ -167,7 +206,7 @@ void findLatestReport_success() {
AnalysisReport mockReport = mock(AnalysisReport.class);
when(mockReport.getAnalysisReportId()).thenReturn("report123");
- when(mockReport.getReport()).thenReturn("테스트 리포트 내용");
+ when(mockReport.getContent()).thenReturn("테스트 리포트 내용");
when(analysisReportRepository.findLatestReport(eq(userId), any(LocalDateTime.class)))
.thenReturn(Optional.of(mockReport));
@@ -178,7 +217,7 @@ void findLatestReport_success() {
// Then
assertThat(result).isEqualTo(mockReport);
assertThat(result.getAnalysisReportId()).isEqualTo("report123");
- assertThat(result.getReport()).isEqualTo("테스트 리포트 내용");
+ assertThat(result.getContent()).isEqualTo("테스트 리포트 내용");
verify(analysisReportRepository).findLatestReport(eq(userId), any(LocalDateTime.class));
}
@@ -200,4 +239,4 @@ void findLatestReport_fail_no_report() {
assertThat(((RestApiException) thrown).getErrorCode())
.isEqualTo(_NOT_FOUND.getCode());
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/test/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCaseTest.java b/src/test/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCaseTest.java
index 7809cde..a8faa96 100644
--- a/src/test/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCaseTest.java
+++ b/src/test/java/opensource/alzheimerdinger/core/domain/relation/application/usecase/RelationManagementUseCaseTest.java
@@ -126,7 +126,7 @@ void send_success() {
relationManagementUseCase.send(guardianId, req);
- verify(relationService).save(patient, guardian, RelationStatus.REQUESTED, Role.GUARDIAN);
+ verify(relationService).upsert(patient, guardian, RelationStatus.REQUESTED);
verify(notificationUseCase).sendRequestNotification(patient, guardian);
}
@@ -141,10 +141,10 @@ void resend_success() {
when(relation.getRelationStatus()).thenReturn(RelationStatus.DISCONNECTED);
when(relation.isMember(user)).thenReturn(false);
- RelationReconnectRequest req = new RelationReconnectRequest(relationId, "guardianId");
+ RelationReconnectRequest req = new RelationReconnectRequest(relationId);
relationManagementUseCase.resend(user.getUserId(), req);
- verify(relation).resend(user.getUserId());
+ verify(relation).resend();
verify(notificationUseCase).sendResendRequestNotification(user, relation);
}
@@ -154,7 +154,7 @@ void resend_fail_notDisconnected() {
when(relationService.findRelation("r1")).thenReturn(relation);
when(relation.getRelationStatus()).thenReturn(RelationStatus.ACCEPTED);
- RelationReconnectRequest req = new RelationReconnectRequest("r1", "guardianId");
+ RelationReconnectRequest req = new RelationReconnectRequest("r1");
Throwable thrown = catchThrowable(() ->
relationManagementUseCase.resend("g1", req)
@@ -172,7 +172,7 @@ void resend_fail_memberUnauthorized() {
when(relation.getRelationStatus()).thenReturn(RelationStatus.DISCONNECTED);
when(relation.isMember(any(User.class))).thenReturn(true);
- RelationReconnectRequest req = new RelationReconnectRequest("r1", "guardianId");
+ RelationReconnectRequest req = new RelationReconnectRequest("r1");
Throwable thrown = catchThrowable(() ->
relationManagementUseCase.resend("g1", req)
diff --git a/src/test/java/opensource/alzheimerdinger/core/domain/transcript/application/usecase/TranscriptUseCaseTest.java b/src/test/java/opensource/alzheimerdinger/core/domain/transcript/application/usecase/TranscriptUseCaseTest.java
new file mode 100644
index 0000000..266e194
--- /dev/null
+++ b/src/test/java/opensource/alzheimerdinger/core/domain/transcript/application/usecase/TranscriptUseCaseTest.java
@@ -0,0 +1,63 @@
+package opensource.alzheimerdinger.core.domain.transcript.application.usecase;
+
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptDetailResponse;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptListResponse;
+import opensource.alzheimerdinger.core.domain.transcript.domain.service.TranscriptService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class TranscriptUseCaseTest {
+
+ @Mock
+ TranscriptService transcriptService;
+
+ @InjectMocks
+ TranscriptUseCase transcriptUseCase;
+
+ @Test
+ void list_success() {
+ // Given
+ String userId = "user-1";
+ TranscriptListResponse mockItem = new TranscriptListResponse(
+ "sess-1", "타이틀", java.time.LocalDate.now(), java.time.LocalTime.NOON, java.time.LocalTime.NOON, "60"
+ );
+ when(transcriptService.getList(userId)).thenReturn(List.of(mockItem));
+
+ // When
+ List result = transcriptUseCase.list(userId);
+
+ // Then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).sessionId()).isEqualTo("sess-1");
+ verify(transcriptService).getList(userId);
+ }
+
+ @Test
+ void detail_success() {
+ // Given
+ String userId = "user-1";
+ String sessionId = "sess-1";
+ TranscriptDetailResponse mockDetail = new TranscriptDetailResponse(
+ sessionId, "타이틀", java.time.LocalDate.now(), java.time.LocalTime.NOON, java.time.LocalTime.NOON, "120", "요약", List.of()
+ );
+ when(transcriptService.getDetail(userId, sessionId)).thenReturn(mockDetail);
+
+ // When
+ TranscriptDetailResponse result = transcriptUseCase.detail(userId, sessionId);
+
+ // Then
+ assertThat(result.sessionId()).isEqualTo(sessionId);
+ verify(transcriptService).getDetail(userId, sessionId);
+ }
+}
+
+
diff --git a/src/test/java/opensource/alzheimerdinger/core/domain/transcript/domain/service/TranscriptServiceTest.java b/src/test/java/opensource/alzheimerdinger/core/domain/transcript/domain/service/TranscriptServiceTest.java
new file mode 100644
index 0000000..afb67c9
--- /dev/null
+++ b/src/test/java/opensource/alzheimerdinger/core/domain/transcript/domain/service/TranscriptServiceTest.java
@@ -0,0 +1,133 @@
+package opensource.alzheimerdinger.core.domain.transcript.domain.service;
+
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptDetailResponse;
+import opensource.alzheimerdinger.core.domain.transcript.application.dto.response.TranscriptListResponse;
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.Speaker;
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.Transcript;
+import opensource.alzheimerdinger.core.domain.transcript.domain.entity.TranscriptMessage;
+import opensource.alzheimerdinger.core.domain.transcript.domain.repository.TranscriptRepository;
+import opensource.alzheimerdinger.core.global.exception.RestApiException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class TranscriptServiceTest {
+
+ @Mock
+ TranscriptRepository transcriptRepository;
+
+ @InjectMocks
+ TranscriptService transcriptService;
+
+ @Test
+ void getList_success() {
+ // Given
+ String userId = "user-1";
+ Instant start = Instant.parse("2024-07-30T00:30:00Z");
+ Instant end = start.plusSeconds(730);
+
+ Transcript t = Transcript.builder()
+ .sessionId("sess_20240730_0030")
+ .userId(userId)
+ .startTime(start)
+ .endTime(end)
+ .summary("요약")
+ .build();
+
+ when(transcriptRepository.findByUser(userId)).thenReturn(List.of(t));
+
+ // When
+ List result = transcriptService.getList(userId);
+
+ // Then
+ assertThat(result).hasSize(1);
+ TranscriptListResponse item = result.get(0);
+ assertThat(item.sessionId()).isEqualTo("sess_20240730_0030");
+ assertThat(item.durationSeconds()).isEqualTo("730");
+ verify(transcriptRepository).findByUser(userId);
+ }
+
+ @Test
+ void getList_fail_no_data() {
+ // Given
+ String userId = "user-1";
+ when(transcriptRepository.findByUser(userId)).thenReturn(List.of());
+
+ // When
+ Throwable thrown = catchThrowable(() -> transcriptService.getList(userId));
+
+ // Then
+ assertThat(thrown).isInstanceOf(RestApiException.class);
+ assertThat(((RestApiException) thrown).getErrorCode()).isEqualTo(_NOT_FOUND.getCode());
+ }
+
+ @Test
+ void getDetail_success() {
+ // Given
+ String userId = "user-1";
+ String sessionId = "sess-1";
+ Instant start = Instant.parse("2024-07-30T09:30:00Z");
+ Instant end = start.plusSeconds(600);
+
+ TranscriptMessage m1 = new TranscriptMessage(Speaker.patient, "안녕하세요");
+ TranscriptMessage m2 = new TranscriptMessage(Speaker.ai, "오늘 기분은 어떠세요?");
+
+ Transcript t = Transcript.builder()
+ .sessionId(sessionId)
+ .userId(userId)
+ .startTime(start)
+ .endTime(end)
+ .summary("기분과 수면 상태에 대한 대화")
+ .conversation(List.of(m1, m2))
+ .build();
+
+ when(transcriptRepository.findBySessionId(sessionId)).thenReturn(Optional.of(t));
+
+ // When
+ TranscriptDetailResponse detail = transcriptService.getDetail(userId, sessionId);
+
+ // Then
+ assertThat(detail.sessionId()).isEqualTo(sessionId);
+ assertThat(detail.summary()).isEqualTo("기분과 수면 상태에 대한 대화");
+ assertThat(detail.durationSeconds()).isEqualTo("600");
+ assertThat(detail.conversation()).hasSize(2);
+ assertThat(detail.conversation().get(0).speaker()).isEqualTo(Speaker.patient);
+ assertThat(detail.conversation().get(1).speaker()).isEqualTo(Speaker.ai);
+ verify(transcriptRepository).findBySessionId(sessionId);
+ }
+
+ @Test
+ void getDetail_fail_wrong_user_or_not_found() {
+ // Given: 세션은 존재하나 userId가 다름 → 필터로 인해 Optional 비게 됨
+ String requestedUserId = "user-A";
+ String sessionId = "sess-X";
+ Transcript t = Transcript.builder()
+ .sessionId(sessionId)
+ .userId("user-B")
+ .startTime(Instant.parse("2024-06-01T10:00:00Z"))
+ .endTime(Instant.parse("2024-06-01T10:10:00Z"))
+ .build();
+ when(transcriptRepository.findBySessionId(sessionId)).thenReturn(Optional.of(t));
+
+ // When
+ Throwable thrown = catchThrowable(() -> transcriptService.getDetail(requestedUserId, sessionId));
+
+ // Then
+ assertThat(thrown).isInstanceOf(RestApiException.class);
+ assertThat(((RestApiException) thrown).getErrorCode()).isEqualTo(_NOT_FOUND.getCode());
+ }
+}
+
+
diff --git a/src/test/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UpdateProfileUseCaseTest.java b/src/test/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UpdateProfileUseCaseTest.java
new file mode 100644
index 0000000..b68d2dd
--- /dev/null
+++ b/src/test/java/opensource/alzheimerdinger/core/domain/user/application/usecase/UpdateProfileUseCaseTest.java
@@ -0,0 +1,144 @@
+package opensource.alzheimerdinger.core.domain.user.application.usecase;
+
+
+import opensource.alzheimerdinger.core.domain.user.application.dto.request.UpdateProfileRequest;
+import opensource.alzheimerdinger.core.domain.user.application.dto.response.ProfileResponse;
+import opensource.alzheimerdinger.core.domain.user.domain.entity.Gender;
+import opensource.alzheimerdinger.core.domain.user.domain.entity.Role;
+import opensource.alzheimerdinger.core.domain.user.domain.entity.User;
+import opensource.alzheimerdinger.core.domain.user.domain.service.UserService;
+import opensource.alzheimerdinger.core.global.exception.RestApiException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.InjectMocks;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._UNAUTHORIZED;
+
+@ExtendWith(MockitoExtension.class)
+class UpdateProfileUseCaseTest {
+
+ @Mock
+ private UserService userService;
+ @Mock private PasswordEncoder passwordEncoder;
+
+ @InjectMocks
+ private UpdateProfileUseCase useCase;
+
+ private User existing;
+
+ @BeforeEach
+ void setUp() {
+ existing = User.builder()
+ .userId("U1")
+ .name("Old Name")
+ .email("old@example.com")
+ .password("$2a$10$oldhash") // 가짜 해시
+ .role(Role.PATIENT)
+ .gender(Gender.MALE)
+ .build();
+ }
+
+ @Test
+ @DisplayName("이름/성별만 변경: 비번은 그대로 유지")
+ void update_profile_without_password_change() {
+ // given
+ UpdateProfileRequest req = new UpdateProfileRequest(
+ "New Name",
+ Gender.FEMALE,
+ null, // currentPassword
+ null // newPassword
+ );
+ when(userService.findUser("U1")).thenReturn(existing);
+
+ // when
+ ProfileResponse res = useCase.update("U1", req);
+
+ // then
+ assertThat(res.name()).isEqualTo("New Name");
+ assertThat(res.gender()).isEqualTo(Gender.FEMALE);
+ assertThat(existing.getPassword()).isEqualTo("$2a$10$oldhash"); // 그대로
+ verify(passwordEncoder, never()).encode(anyString());
+ verify(passwordEncoder, never()).matches(any(), any());
+ }
+
+ @Test
+ @DisplayName("비번 변경: 현재 비번 일치 → 새 비번 해싱 저장")
+ void update_profile_with_password_change_success() {
+ // given
+ UpdateProfileRequest req = new UpdateProfileRequest(
+ "New Name",
+ Gender.FEMALE,
+ "current-plain", // 현재 비번
+ "new-plain" // 새 비번
+ );
+ when(userService.findUser("U1")).thenReturn(existing);
+ when(passwordEncoder.matches("current-plain", "$2a$10$oldhash")).thenReturn(true);
+ when(passwordEncoder.encode("new-plain")).thenReturn("$2a$10$newhash");
+
+ // when
+ ProfileResponse res = useCase.update("U1", req);
+
+ // then
+ assertThat(res.name()).isEqualTo("New Name");
+ assertThat(res.gender()).isEqualTo(Gender.FEMALE);
+ assertThat(existing.getPassword()).isEqualTo("$2a$10$newhash");
+ verify(passwordEncoder).matches("current-plain", "$2a$10$oldhash");
+ verify(passwordEncoder).encode("new-plain");
+ }
+
+ @Test
+ @DisplayName("비번 변경: 현재 비번 불일치 → UNAUTHORIZED 예외")
+ void update_profile_with_password_change_mismatch() {
+ // given
+ UpdateProfileRequest req = new UpdateProfileRequest(
+ "New Name",
+ Gender.FEMALE,
+ "wrong-current",
+ "new-plain"
+ );
+ when(userService.findUser("U1")).thenReturn(existing);
+ when(passwordEncoder.matches("wrong-current", "$2a$10$oldhash")).thenReturn(false);
+
+ // when / then
+ assertThatThrownBy(() -> useCase.update("U1", req))
+ .isInstanceOf(RestApiException.class)
+ .satisfies(ex -> {
+ RestApiException e = (RestApiException) ex;
+ // 401 (UNAUTHORIZED)
+ assertThat(e.getErrorCode().getHttpStatus().value()).isEqualTo(401);
+ // 프로젝트 통일 코드라면 (예: COMMON401)
+ assertThat(e.getErrorCode().getCode()).isEqualTo("COMMON401");
+ });
+
+ // 비번은 그대로
+ assertThat(existing.getPassword()).isEqualTo("$2a$10$oldhash");
+ verify(passwordEncoder, never()).encode(anyString());
+ }
+
+ @Test
+ @DisplayName("UserService에서 _NOT_FOUND 던질 때 그대로 전파")
+ void user_not_found_bubbles_up() {
+ // given
+ UpdateProfileRequest req = new UpdateProfileRequest(
+ "New Name",
+ Gender.FEMALE,
+ null,
+ null
+ );
+ when(userService.findUser("U2")).thenThrow(new RestApiException(
+ opensource.alzheimerdinger.core.global.exception.code.status.GlobalErrorStatus._NOT_FOUND
+ ));
+
+ // when / then
+ assertThatThrownBy(() -> useCase.update("U2", req))
+ .isInstanceOf(RestApiException.class);
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
index 5827d38..a4a6f89 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -109,8 +109,8 @@ logging:
org.apache.kafka: WARN
org.springframework.kafka: INFO
-CORS:
- allowed-origins: "localhost:5173"
- allowed-methods: "GET,POST,PUT,DELETE,PATCH,OPTIONS"
- allowed-headers: "Authorization"
+cors:
+ allowed-origins: "http://localhost:5173"
+ allowed-methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
+ allowed-headers: "Authorization,Content-Type,Accept,X-Requested-With,DNT,User-Agent,If-Modified-Since,Cache-Control,Range"
max-age: 3600
\ No newline at end of file