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 (알츠하이머딩거) — 치매 환자 케어 웹앱

+ +

+ + Swagger API Docs Badge + + MIT License Badge +

+ +

보호자–환자 연결, 통화 기록 분석, 감정 리포트, 리마인더와 알림을 제공하는 치매 환자 케어 서비스

+
+ +
+ + + + +
+ + +
+

📖 프로젝트 소개

+ +

+ 본 프로젝트는 치매 환자와 보호자를 위한 AI 동반 케어 웹앱입니다. + 환자는 앱에서 인공지능과 실시간 대화(음성/자막)로 일상을 공유하고, + 보호자는 연결 계정을 통해 심리 상태와 이상 징후를 모니터링합니다. + 하루하루 축적되는 대화·활동 데이터를 분석해 일·주·월 단위 종합 리포트 + (감정 타임라인, 참여도, 평균 통화시간, 위험 지표)를 제공하여 세심한 돌봄 계획 수립을 돕습니다. +

+ + +
+ +
+ + +
+

👥 팀원 구성

+ + + + + + + + + + + + + + + + +
+ + 정장우 프로필 이미지
+ 정장우 +

+ 팀 리더 · 백엔드
주요 도메인 · 인프라 구축
+
+ + 김경규 프로필 이미지
+ 김경규 +

+ 백엔드
도메인 · 인증/인가
시스템/인프라 설계
+
+ + 박영두 프로필 이미지
+ 박영두 +

+ 백엔드
도메인 · 인프라 구축 · CI/CD · 모니터링
+
+ + 노예원 프로필 이미지
+ 노예원 +

+ 프론트
UI/UX · 통화 WebSocket · CD · FCM
+
+ + 김효신 프로필 이미지
+ 김효신 +

+ 프론트
UI/UX · API 연동 · 상태관리
+
+ + 서현교 프로필 이미지
+ 서현교 +

+ AI
아이디어 · RAG 메모리 · 분석 리포트
+
+ + 강민재 프로필 이미지
+ 강민재 +

+ AI
실시간 통화 · 감정 분석·요약
+
+ +
+
+ +
+ + +
+

🧰 기술 스택

+ + +

Frontend

+

+ React + TypeScript + Vite + React Router + styled-components + Recharts + Axios + PWA +

+ + +

Backend

+

+ Java + Spring Boot + Spring Security + Spring Data JPA + Spring Actuator + Spring Batch + FastAPI +

+ + +

AI / Data

+

+ Vertex AI + Gemini Live API + Hugging Face Inference + Pinecone +

+ + +

Database / Messaging / Caching

+

+ MySQL + MongoDB + Redis + Apache Kafka +

+ + +

Infra / DevOps

+

+ GCP Compute Engine + Google Cloud Storage + Artifact Registry + Docker + Nginx + GitHub Actions + Cloudflare +

+ + +

Monitoring / Docs / Test

+

+ Micrometer + Prometheus + Grafana + + Swagger API Docs + + JUnit 5 + Postman +

+ + +

Push / Notification

+

+ FCM + Firebase Admin SDK +

+
+ +
+ + +
+

🔑 주요 API (요약)

+ +

전체 스펙은 Swagger에서 확인: https://api.alzheimerdinger.com/swagger-ui/index.html#/

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpoint설명인증
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 기반)

+ + +

PR 규칙

+ + +

커밋 컨벤션 (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주차)배포·모니터링·운영릴리즈 노트, 대시보드, 알림 룰
+ +

작업 관리 방식

+ +
+ +
+ + +
+

🧠 핵심 기능 구현 내용

+ + +

1) 실시간 AI 기반 통화 제공

+

+ 환자와 AI가 음성으로 대화하고, 실시간 자막을 제공하는 통화 기능을 구현했습니다. + 통화 전/중/후 상태를 명확히 분리하고, 오디오 스트림 처리와 스트리밍 응답을 안정적으로 연결합니다. +

+ +

① UI 흐름

+

CallWaitingCallActiveCallEnd (종료 후 요약/저장)

+ + +

② 오디오 처리

+ + +

③ 실시간 연결

+ + +
+ + +

2) 사용자 맞춤형 통합 보고서

+

+ 일간/기간 종합 관점에서 감정 및 이용 지표를 시각화합니다. 날짜/기간 선택에 따라 API 파라미터를 구성하고, + 전처리된 데이터로 그래프/지표 컴포넌트를 렌더링합니다. +

+ +

① 일간(DailySection)

+ + +

② 종합(TotalSection)

+ + +

③ 데이터 흐름(요약)

+ +
+ +
+ + +
+

🧭 페이지별 기능

+ +
+ Splash · 온보딩 + +
+ +
+ 로그인/회원가입 + +
+ +
+ 프로필 + +
+ +
+ 관계 관리 + +
+ +
+ 통화(실시간 AI) + +
+ +
+ 통화 기록(Transcripts) + +
+ +
+ 분석 리포트 + +
+ +
+ 리마인더 + +
+ +
+ 설정/로그아웃 + +
+ +
+ 피드백 + +
+ +

맨 위로 ⤴

+
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