From 98ade119caf433867ddf1ba3de357cb25d945ada Mon Sep 17 00:00:00 2001 From: hyeseong Date: Thu, 13 Aug 2026 18:00:30 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(web):=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=20RTC=20=EC=A7=81=EC=A0=91=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EA=B3=BC=20=EB=B0=9C=ED=99=94=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveKit SFU를 P2P WebRTC mesh로 교체한다. 서버에 이미 있는 offer/answer/ice 중계(MeetingSocketHandler.forwardSignal)를 그대로 쓰고, 회의 WebSocket 하나로 roster·자막·시그널링을 함께 나른다. - useWebRtcMesh: 오디오 전용 mesh. memberId가 작은 쪽이 offer를 걸어 glare를 막고, 마이크 확보 전 도착한 offer/ice는 버퍼링했다가 처리한다. - 발화 표시: 로컬·원격 스트림 RMS를 100ms로 샘플링해 참가자 타일에 표시 - LiveKit 제거: useLiveKitRoom, livekit-client, getRtcToken, RTC_TOKEN - hasRtcToken -> hasLocalMedia. 토큰이 없어져 상태 문구도 마이크 기준으로 변경 Co-Authored-By: Claude Opus 5 --- web/AGENTS.md | 2 +- web/package.json | 1 - web/pnpm-lock.yaml | 119 ---- web/src/apis/meetings.ts | 18 - web/src/constants/endpoint.ts | 2 - web/src/pages/meeting/InProgressPage.tsx | 5 +- .../components/MeetingConnectionOverlay.tsx | 12 +- .../meeting/components/ParticipantGrid.tsx | 1 + .../meeting/components/ParticipantTile.tsx | 13 +- web/src/pages/meeting/constants.ts | 16 + web/src/pages/meeting/hooks/useLiveKitRoom.ts | 237 -------- web/src/pages/meeting/hooks/useMeetingRoom.ts | 76 ++- .../meeting/hooks/useMeetingSignaling.ts | 52 ++ web/src/pages/meeting/hooks/useWebRtcMesh.ts | 507 ++++++++++++++++++ web/src/pages/meeting/types.ts | 15 + web/src/types/meeting.ts | 7 - 16 files changed, 659 insertions(+), 424 deletions(-) create mode 100644 web/src/pages/meeting/constants.ts delete mode 100644 web/src/pages/meeting/hooks/useLiveKitRoom.ts create mode 100644 web/src/pages/meeting/hooks/useWebRtcMesh.ts diff --git a/web/AGENTS.md b/web/AGENTS.md index 3a4008e..80e8402 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -17,7 +17,7 @@ | 서버 상태 | TanStack Query 5 | 서버에서 온 데이터는 전부 여기 | | HTTP | Axios (`@/utils/http`) | JWT 인터셉터 내장 | | 스타일 | Tailwind CSS 4 | CSS Modules 사용 안 함 | -| 실시간 | `livekit-client` | 회의 화면 전용 | +| 실시간 | WebRTC P2P mesh + WebSocket | 회의 화면 전용. 시그널링은 서버 WS가 중계 | | 검사 | Biome 2.4.7 | 포맷 + 린트 + import 정렬 | | 패키지 | pnpm | `pnpm-lock.yaml` 커밋 필수 | diff --git a/web/package.json b/web/package.json index 6645266..c00f0a5 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,6 @@ "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.90.7", "axios": "^1.13.2", - "livekit-client": "^2.18.1", "react": "^19.2.0", "react-diff-viewer-continued": "^4.2.2", "react-dom": "^19.2.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e5107a7..b94d811 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: axios: specifier: ^1.13.2 version: 1.13.6 - livekit-client: - specifier: ^2.18.1 - version: 2.18.1(@types/dom-mediacapture-record@1.0.22) react: specifier: ^19.2.0 version: 19.2.4 @@ -172,28 +169,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.7': resolution: {integrity: sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.7': resolution: {integrity: sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.7': resolution: {integrity: sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.7': resolution: {integrity: sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==} @@ -207,9 +200,6 @@ packages: cpu: [x64] os: [win32] - '@bufbuild/protobuf@1.10.1': - resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} - '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -426,12 +416,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@livekit/mutex@1.1.1': - resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - - '@livekit/protocol@1.44.0': - resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==} - '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -478,79 +462,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -688,28 +659,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -764,9 +731,6 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/dom-mediacapture-record@1.0.22': - resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -935,10 +899,6 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1025,9 +985,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1083,28 +1040,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1125,15 +1078,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - livekit-client@2.18.1: - resolution: {integrity: sha512-nGjuEEV1mVN01EcAMwGIwG3J1gpBMqwn2V4R6W/8zz9Rah1CaAohIm6AMLG7BdctQpyeh34dfAOLfDodIsWyYA==} - peerDependencies: - '@types/dom-mediacapture-record': ^1 - - loglevel@1.9.2: - resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} - engines: {node: '>= 0.6.0'} - lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -1255,19 +1199,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - sdp-transform@2.15.0: - resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==} - hasBin: true - - sdp@3.2.2: - resolution: {integrity: sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1310,9 +1244,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typed-emitter@2.1.0: - resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1372,10 +1303,6 @@ packages: yaml: optional: true - webrtc-adapter@9.0.4: - resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==} - engines: {node: '>=6.0.0', npm: '>=3.10.0'} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1534,8 +1461,6 @@ snapshots: '@biomejs/cli-win32-x64@2.4.7': optional: true - '@bufbuild/protobuf@1.10.1': {} - '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 @@ -1707,12 +1632,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@livekit/mutex@1.1.1': {} - - '@livekit/protocol@1.44.0': - dependencies: - '@bufbuild/protobuf': 1.10.1 - '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/pluginutils@5.3.0(rollup@4.59.0)': @@ -1964,8 +1883,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/dom-mediacapture-record@1.0.22': {} - '@types/estree@1.0.8': {} '@types/node@24.12.0': @@ -2148,8 +2065,6 @@ snapshots: estree-walker@2.0.2: {} - events@3.3.0: {} - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2226,8 +2141,6 @@ snapshots: jiti@2.6.1: {} - jose@6.2.2: {} - js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -2291,21 +2204,6 @@ snapshots: lines-and-columns@1.2.4: {} - livekit-client@2.18.1(@types/dom-mediacapture-record@1.0.22): - dependencies: - '@livekit/mutex': 1.1.1 - '@livekit/protocol': 1.44.0 - '@types/dom-mediacapture-record': 1.0.22 - events: 3.3.0 - jose: 6.2.2 - loglevel: 1.9.2 - sdp-transform: 2.15.0 - tslib: 2.8.1 - typed-emitter: 2.1.0 - webrtc-adapter: 9.0.4 - - loglevel@1.9.2: {} - lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -2445,17 +2343,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - optional: true - scheduler@0.27.0: {} - sdp-transform@2.15.0: {} - - sdp@3.2.2: {} - semver@6.3.1: {} set-cookie-parser@2.7.2: {} @@ -2486,10 +2375,6 @@ snapshots: tslib@2.8.1: {} - typed-emitter@2.1.0: - optionalDependencies: - rxjs: 7.8.2 - typescript@5.9.3: {} undici-types@7.16.0: {} @@ -2525,10 +2410,6 @@ snapshots: jiti: 2.6.1 lightningcss: 1.31.1 - webrtc-adapter@9.0.4: - dependencies: - sdp: 3.2.2 - yallist@3.1.1: {} yaml@1.10.3: {} diff --git a/web/src/apis/meetings.ts b/web/src/apis/meetings.ts index 049d00e..d24b463 100644 --- a/web/src/apis/meetings.ts +++ b/web/src/apis/meetings.ts @@ -11,7 +11,6 @@ import type { MeetingHistory, MeetingListItem, MeetingStatus, - RtcTokenResult, } from "@/types/meeting"; import { http } from "@/utils/http"; @@ -58,23 +57,6 @@ export const getMeetingDetail = async ( return data.result; }; -export const getRtcToken = async ( - meetingId: number, -): Promise<{ serverUrl: string; token: string; roomName: string }> => { - const { data } = await http.get< - unknown, - { data: ApiResponse } - >(ENDPOINT.MEETINGS.RTC_TOKEN(meetingId)); - if (!data.isSuccess) { - throw new Error(data.message); - } - return { - serverUrl: data.result.server_url, - token: data.result.token, - roomName: data.result.room_name, - }; -}; - export const getMeetingAnalysis = async ( meetingId: number, ): Promise => { diff --git a/web/src/constants/endpoint.ts b/web/src/constants/endpoint.ts index eb652da..0e48458 100644 --- a/web/src/constants/endpoint.ts +++ b/web/src/constants/endpoint.ts @@ -30,8 +30,6 @@ const ENDPOINT = { LIST: (teamId: number) => `${API_BASE_URL}/api/teams/${teamId}/meetings`, DETAIL: (meetingId: number) => `${API_BASE_URL}/api/meetings/${meetingId}`, DELETE: (meetingId: number) => `${API_BASE_URL}/api/meetings/${meetingId}`, - RTC_TOKEN: (meetingId: number) => - `${API_BASE_URL}/api/meetings/${meetingId}/rtc-token`, END: (meetingId: number) => `${API_BASE_URL}/api/meetings/${meetingId}/end`, ANALYSIS: (meetingId: number) => `${API_BASE_URL}/api/meetings/${meetingId}/analysis`, diff --git a/web/src/pages/meeting/InProgressPage.tsx b/web/src/pages/meeting/InProgressPage.tsx index b30e7b0..4ccfac2 100644 --- a/web/src/pages/meeting/InProgressPage.tsx +++ b/web/src/pages/meeting/InProgressPage.tsx @@ -60,7 +60,7 @@ const InProgressPage = () => { participants, isWsConnected, isRoomConnected, - hasRtcToken, + hasLocalMedia, retryAttempt, errorMessage, transcripts, @@ -75,6 +75,7 @@ const InProgressPage = () => { } = useMeetingRoom({ meetingId, displayName: myName, + selfMemberId: myMemberId, }); const MAX_CONNECTION_RETRIES = 3; @@ -154,7 +155,7 @@ const InProgressPage = () => { { name={p.name} isSelf={p.isSelf} profileImage={p.profileImage} + isSpeaking={p.isSpeaking} /> ))} diff --git a/web/src/pages/meeting/components/ParticipantTile.tsx b/web/src/pages/meeting/components/ParticipantTile.tsx index 0e6fdbe..a48423a 100644 --- a/web/src/pages/meeting/components/ParticipantTile.tsx +++ b/web/src/pages/meeting/components/ParticipantTile.tsx @@ -5,18 +5,25 @@ interface ParticipantTileProps { name: string; isSelf?: boolean; profileImage?: string | null; + isSpeaking?: boolean; } const ParticipantTile = ({ name, isSelf, profileImage, + isSpeaking, }: ParticipantTileProps) => { + // 발화 표시가 본인 테두리보다 우선한다. 둘 다 테두리라 겹치면 구분이 안 된다. + const borderClass = isSpeaking + ? "ring-4 ring-(--color-blue-500)" + : isSelf + ? "border-2 border-green-500" + : ""; + return (
{profileImage ? ( { - const [participants, setParticipants] = useState([]); - const [isConnected, setIsConnected] = useState(false); - const [hasRtcToken, setHasRtcToken] = useState(false); - const [retryAttempt, setRetryAttempt] = useState(0); - const [errorMessage, setErrorMessage] = useState(null); - const [isMicEnabled, setIsMicEnabled] = useState(true); - const [isAudioOutputEnabled, setIsAudioOutputEnabled] = useState(true); - const [, setRetrySignal] = useState(0); - - const roomRef = useRef(null); - // 이벤트 콜백/connect 이후 시점에 최신 의도 상태를 참조하기 위한 ref - const isAudioOutputEnabledRef = useRef(true); - const isMicEnabledRef = useRef(true); - - const setMicrophoneEnabled = useCallback(async (enabled: boolean) => { - isMicEnabledRef.current = enabled; - const room = roomRef.current; - if (!room) { - setIsMicEnabled(enabled); - return; - } - try { - await room.localParticipant.setMicrophoneEnabled(enabled); - setIsMicEnabled(enabled); - } catch { - /* mic 권한 거부 등 실패 시 상태 변경 안 함 */ - } - }, []); - - const setAudioOutputEnabled = useCallback((enabled: boolean) => { - isAudioOutputEnabledRef.current = enabled; - setIsAudioOutputEnabled(enabled); - const room = roomRef.current; - if (!room) return; - room.remoteParticipants.forEach((p) => { - p.audioTrackPublications.forEach((pub) => { - const track = pub.track; - if (!track) return; - track.attachedElements.forEach((el) => { - el.volume = enabled ? 1 : 0; - }); - }); - }); - }, []); - - const disconnect = useCallback(async () => { - const room = roomRef.current; - isMicEnabledRef.current = false; - setIsMicEnabled(false); - if (!room) { - setIsConnected(false); - return; - } - try { - await room.localParticipant.setMicrophoneEnabled(false); - } catch { - /* noop */ - } - try { - await room.disconnect(); - } catch { - /* noop */ - } - setIsConnected(false); - }, []); - - const manualRetry = useCallback(() => { - setErrorMessage(null); - setRetryAttempt(0); - setHasRtcToken(false); - setIsConnected(false); - setRetrySignal((n) => n + 1); - }, []); - - useEffect(() => { - if (meetingId == null) return; - - let cancelled = false; - const room = new Room(); - roomRef.current = room; - - room.on(RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { - setParticipants((prev) => { - if (prev.some((x) => x.id === p.identity)) return prev; - return [...prev, { id: p.identity, name: p.name || p.identity }]; - }); - }); - room.on(RoomEvent.ParticipantDisconnected, (p: RemoteParticipant) => { - setParticipants((prev) => prev.filter((x) => x.id !== p.identity)); - }); - - // 원격 audio track 을 DOM 에 attach 해 실제 재생되도록 하고, 현재 듣기 상태 적용 - const attachAudioTrack = (track: RemoteTrack) => { - if (track.kind !== Track.Kind.Audio) return; - const apply = (el: HTMLMediaElement) => { - el.volume = isAudioOutputEnabledRef.current ? 1 : 0; - }; - if (track.attachedElements.length === 0) { - const el = track.attach(); - el.style.display = "none"; - document.body.appendChild(el); - } - track.attachedElements.forEach(apply); - track.on(TrackEvent.ElementAttached, apply); - }; - - room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack) => { - attachAudioTrack(track); - }); - - // 트랙 구독 해제 시 audio element 정리 + 리스너 제거 - room.on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => { - if (track.kind !== Track.Kind.Audio) return; - track.detach().forEach((el) => { - el.remove(); - }); - track.removeAllListeners(TrackEvent.ElementAttached); - }); - - let attempt = 0; - const tryConnect = async (): Promise => { - try { - const { serverUrl, token } = await getRtcToken(meetingId); - if (cancelled) return; - setHasRtcToken(true); - - await room.connect(serverUrl, token); - if (cancelled) { - await room.disconnect(); - return; - } - - // 이미 입장해 있던 참가자/오디오 트랙 시드 (후발 입장 시나리오) - const initial: MeetingParticipant[] = []; - room.remoteParticipants.forEach((p) => { - initial.push({ id: p.identity, name: p.name || p.identity }); - p.audioTrackPublications.forEach((pub) => { - if (pub.track) attachAudioTrack(pub.track); - }); - }); - if (initial.length > 0) { - setParticipants((prev) => { - const merged = [...prev]; - initial.forEach((p) => { - if (!merged.some((x) => x.id === p.id)) merged.push(p); - }); - return merged; - }); - } - - setIsConnected(true); - setErrorMessage(null); - setRetryAttempt(0); - // 사용자가 직전에 mute 했어도 그 의도를 보존 - await room.localParticipant.setMicrophoneEnabled( - isMicEnabledRef.current, - ); - } catch (err) { - if (cancelled) return; - attempt += 1; - setRetryAttempt(attempt); - - if (attempt < MAX_RETRIES) { - // backoff 후 재시도 - await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); - if (cancelled) return; - try { - await room.disconnect(); - } catch { - /* noop */ - } - return tryConnect(); - } - - setErrorMessage( - err instanceof Error ? err.message : "회의방 연결 실패", - ); - } - }; - tryConnect(); - - return () => { - cancelled = true; - room.disconnect().catch(() => { - /* noop */ - }); - roomRef.current = null; - setIsConnected(false); - setHasRtcToken(false); - setParticipants([]); - // 이전 회의 에러가 새 화면에 잔존하지 않도록 초기화 - setErrorMessage(null); - }; - }, [meetingId]); - - return { - participants, - isConnected, - hasRtcToken, - retryAttempt, - errorMessage, - isMicEnabled, - isAudioOutputEnabled, - setMicrophoneEnabled, - setAudioOutputEnabled, - manualRetry, - disconnect, - }; -}; diff --git a/web/src/pages/meeting/hooks/useMeetingRoom.ts b/web/src/pages/meeting/hooks/useMeetingRoom.ts index 0c2de2a..00419bf 100644 --- a/web/src/pages/meeting/hooks/useMeetingRoom.ts +++ b/web/src/pages/meeting/hooks/useMeetingRoom.ts @@ -1,64 +1,84 @@ import { useCallback, useEffect, useMemo } from "react"; import type { RoomParticipant } from "../types"; -import { useLiveKitRoom } from "./useLiveKitRoom"; import { useMeetingSignaling } from "./useMeetingSignaling"; +import { useWebRtcMesh } from "./useWebRtcMesh"; interface UseMeetingRoomOptions { meetingId: number | null; displayName: string; + /** JWT에서 꺼낸 본인 memberId. mesh의 offer 방향과 본인 타일 표시에 쓴다. */ + selfMemberId: number | null; } /** * 회의방 통합 훅. - * - WebSocket(signaling, transcripts) + LiveKit(SFU, 미디어) 두 채널을 묶어 - * 하나의 인터페이스로 노출한다. - * - 참가자 목록은 두 소스를 병합 (id 기준 중복 제거) - * - 연결 진행 상태(hasRtcToken, retryAttempt)와 수동 재시도(manualRetry) 노출 + * - WebSocket 하나로 signaling(roster·자막)과 WebRTC offer/answer/ice를 함께 나른다. + * - 미디어는 P2P mesh(useWebRtcMesh). 참가자 목록의 정본은 서버 roster다. + * - 연결 진행 상태(hasLocalMedia, retryAttempt)와 수동 재시도(manualRetry) 노출 */ export const useMeetingRoom = ({ meetingId, displayName, + selfMemberId, }: UseMeetingRoomOptions) => { const signaling = useMeetingSignaling({ meetingId, displayName }); - const livekit = useLiveKitRoom({ meetingId }); - const participants = useMemo(() => { - const map = new Map(); - [...signaling.participants, ...livekit.participants].forEach((p) => { - if (!map.has(p.id)) map.set(p.id, p); - }); - return Array.from(map.values()); - }, [signaling.participants, livekit.participants]); + const peerMemberIds = useMemo( + () => + signaling.participants + .map((participant) => Number(participant.id)) + .filter((id) => Number.isFinite(id)), + [signaling.participants], + ); + + const mesh = useWebRtcMesh({ + meetingId, + selfMemberId, + peerMemberIds, + isWsConnected: signaling.isConnected, + sendSignal: signaling.sendSignal, + subscribeSignal: signaling.subscribeSignal, + }); + + const participants = useMemo( + () => + signaling.participants.map((participant) => ({ + ...participant, + isSelf: participant.id === String(selfMemberId), + isSpeaking: mesh.speakingMemberIds.includes(participant.id), + })), + [signaling.participants, mesh.speakingMemberIds, selfMemberId], + ); const manualRetry = useCallback(() => { signaling.manualRetry(); - livekit.manualRetry(); - }, [signaling.manualRetry, livekit.manualRetry]); + mesh.manualRetry(); + }, [signaling.manualRetry, mesh.manualRetry]); - const retryAttempt = Math.max(signaling.retryAttempt, livekit.retryAttempt); + const retryAttempt = Math.max(signaling.retryAttempt, mesh.retryAttempt); - // 타인이 회의를 종료한 시그널이 오면 LiveKit Room/마이크 즉시 해제 + // 타인이 회의를 종료한 시그널이 오면 peer 연결과 마이크 트랙 즉시 해제 + const meshDisconnect = mesh.disconnect; useEffect(() => { if (!signaling.isMeetingEnded) return; - void livekit.isConnected; - void livekit.disconnect(); - }, [signaling.isMeetingEnded, livekit.isConnected, livekit.disconnect]); + meshDisconnect(); + }, [signaling.isMeetingEnded, meshDisconnect]); return { participants, isWsConnected: signaling.isConnected, - isRoomConnected: livekit.isConnected, - hasRtcToken: livekit.hasRtcToken, + isRoomConnected: mesh.isConnected, + hasLocalMedia: mesh.hasLocalMedia, retryAttempt, - // LiveKit 에러를 우선 노출, 없으면 signaling 에러 - errorMessage: livekit.errorMessage ?? signaling.errorMessage, + // 미디어 에러를 우선 노출, 없으면 signaling 에러 + errorMessage: mesh.errorMessage ?? signaling.errorMessage, transcripts: signaling.transcripts, interimByMember: signaling.interimByMember, sendMessage: signaling.sendMessage, - isMicEnabled: livekit.isMicEnabled, - isAudioOutputEnabled: livekit.isAudioOutputEnabled, - setMicrophoneEnabled: livekit.setMicrophoneEnabled, - setAudioOutputEnabled: livekit.setAudioOutputEnabled, + isMicEnabled: mesh.isMicEnabled, + isAudioOutputEnabled: mesh.isAudioOutputEnabled, + setMicrophoneEnabled: mesh.setMicrophoneEnabled, + setAudioOutputEnabled: mesh.setAudioOutputEnabled, manualRetry, isMeetingEnded: signaling.isMeetingEnded, }; diff --git a/web/src/pages/meeting/hooks/useMeetingSignaling.ts b/web/src/pages/meeting/hooks/useMeetingSignaling.ts index 5d031f6..2bcc8e2 100644 --- a/web/src/pages/meeting/hooks/useMeetingSignaling.ts +++ b/web/src/pages/meeting/hooks/useMeetingSignaling.ts @@ -5,9 +5,16 @@ import type { InterimEntry, OutgoingMessageType, RoomParticipant, + SignalFrame, + SignalMessageType, + SignalPayload, TranscriptEntry, } from "../types"; +type SignalHandler = (frame: SignalFrame) => void; + +const SIGNAL_TYPES: readonly string[] = ["offer", "answer", "ice"]; + interface UseMeetingSignalingOptions { meetingId: number | null; displayName: string; @@ -29,6 +36,8 @@ interface SignalingFrame { name?: string; text?: string; timestamp?: string; + target_member_id?: number; + payload?: SignalPayload; } const MAX_RETRIES = 3; @@ -62,6 +71,8 @@ export const useMeetingSignaling = ({ const [, setRetrySignal] = useState(0); const wsRef = useRef(null); + // 시그널링 구독자. state로 두면 프레임마다 리렌더가 나므로 ref로 관리한다. + const signalHandlersRef = useRef>(new Set()); const sendMessage = useCallback((type: OutgoingMessageType, text: string) => { const ws = wsRef.current; @@ -70,6 +81,30 @@ export const useMeetingSignaling = ({ return true; }, []); + // 서버는 target_member_id 가 없는 offer/answer/ice 를 에러로 돌려준다. + const sendSignal = useCallback( + ( + type: SignalMessageType, + targetMemberId: number, + payload: SignalPayload, + ) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send( + JSON.stringify({ type, target_member_id: targetMemberId, payload }), + ); + return true; + }, + [], + ); + + const subscribeSignal = useCallback((handler: SignalHandler) => { + signalHandlersRef.current.add(handler); + return () => { + signalHandlersRef.current.delete(handler); + }; + }, []); + const manualRetry = useCallback(() => { setErrorMessage(null); setRetryAttempt(0); @@ -126,6 +161,21 @@ export const useMeetingSignaling = ({ ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data) as SignalingFrame; + + // offer/answer/ice 는 상태로 쌓지 않고 구독자(useWebRtcMesh)에게 바로 넘긴다. + if (SIGNAL_TYPES.includes(msg.type)) { + if (msg.from_member_id == null) return; + const frame: SignalFrame = { + type: msg.type as SignalMessageType, + fromMemberId: msg.from_member_id, + payload: msg.payload ?? {}, + }; + signalHandlersRef.current.forEach((handler) => { + handler(frame); + }); + return; + } + switch (msg.type) { case "connected": case "roster": { @@ -281,6 +331,8 @@ export const useMeetingSignaling = ({ transcripts, interimByMember, sendMessage, + sendSignal, + subscribeSignal, manualRetry, isMeetingEnded, }; diff --git a/web/src/pages/meeting/hooks/useWebRtcMesh.ts b/web/src/pages/meeting/hooks/useWebRtcMesh.ts new file mode 100644 index 0000000..e9c0121 --- /dev/null +++ b/web/src/pages/meeting/hooks/useWebRtcMesh.ts @@ -0,0 +1,507 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + MAX_MEDIA_RETRIES, + MEDIA_RETRY_DELAY_MS, + RTC_CONFIG, + SPEAKING_HOLD_MS, + SPEAKING_RMS_THRESHOLD, + SPEAKING_SAMPLE_INTERVAL_MS, +} from "../constants"; +import type { SignalFrame, SignalMessageType, SignalPayload } from "../types"; + +interface UseWebRtcMeshOptions { + meetingId: number | null; + /** JWT에서 꺼낸 본인 memberId. offer 방향 결정에 쓰이므로 없으면 mesh를 시작하지 않는다. */ + selfMemberId: number | null; + /** roster 기준 참가자 memberId 목록 (본인 포함 가능, 내부에서 제외한다) */ + peerMemberIds: number[]; + isWsConnected: boolean; + sendSignal: ( + type: SignalMessageType, + targetMemberId: number, + payload: SignalPayload, + ) => boolean; + subscribeSignal: (handler: (frame: SignalFrame) => void) => () => void; +} + +interface PeerEntry { + connection: RTCPeerConnection; + audioElement: HTMLAudioElement | null; + analyser: AnalyserNode | null; +} + +/** + * WebSocket 시그널링 위에 올린 P2P WebRTC mesh (오디오 전용). + * - LiveKit SFU를 대체한다. 서버는 offer/answer/ice를 1:1 중계만 한다. + * - offer는 memberId가 작은 쪽이 건다 (glare 방지). 재협상은 하지 않는다. + * - 로컬/원격 스트림 볼륨을 재서 발화 중인 참가자를 표시한다. + */ +export const useWebRtcMesh = ({ + meetingId, + selfMemberId, + peerMemberIds, + isWsConnected, + sendSignal, + subscribeSignal, +}: UseWebRtcMeshOptions) => { + const [hasLocalMedia, setHasLocalMedia] = useState(false); + const [retryAttempt, setRetryAttempt] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const [isMicEnabled, setIsMicEnabled] = useState(true); + const [isAudioOutputEnabled, setIsAudioOutputEnabled] = useState(true); + const [speakingMemberIds, setSpeakingMemberIds] = useState([]); + const [retrySignal, setRetrySignal] = useState(0); + + const localStreamRef = useRef(null); + const peersRef = useRef>(new Map()); + // setRemoteDescription 전에 도착한 ICE 후보를 peer별로 모아둔다. + const pendingIceRef = useRef>(new Map()); + // 마이크 확보 전에 도착한 offer/ice. 버리면 상대가 재시도하지 않아 연결이 영영 안 선다. + const pendingSignalsRef = useRef([]); + const audioContextRef = useRef(null); + const localAnalyserRef = useRef(null); + const isMicEnabledRef = useRef(true); + const isAudioOutputEnabledRef = useRef(true); + const hasLocalMediaRef = useRef(false); + + // 이벤트 콜백에서 최신 값을 읽되, 값이 바뀌어도 연결을 다시 세우지 않도록 ref로 잡아둔다. + const sendSignalRef = useRef(sendSignal); + sendSignalRef.current = sendSignal; + const selfMemberIdRef = useRef(selfMemberId); + selfMemberIdRef.current = selfMemberId; + + const setMicrophoneEnabled = useCallback((enabled: boolean) => { + isMicEnabledRef.current = enabled; + setIsMicEnabled(enabled); + // 트랙을 제거하지 않고 enabled만 끈다. 제거하면 재협상이 필요해진다. + localStreamRef.current?.getAudioTracks().forEach((track) => { + track.enabled = enabled; + }); + }, []); + + const setAudioOutputEnabled = useCallback((enabled: boolean) => { + isAudioOutputEnabledRef.current = enabled; + setIsAudioOutputEnabled(enabled); + peersRef.current.forEach((peer) => { + if (peer.audioElement) peer.audioElement.volume = enabled ? 1 : 0; + }); + }, []); + + const closePeer = useCallback((memberId: number) => { + const peer = peersRef.current.get(memberId); + if (!peer) return; + + peer.connection.onicecandidate = null; + peer.connection.ontrack = null; + peer.connection.onconnectionstatechange = null; + try { + peer.connection.close(); + } catch { + /* 이미 닫힌 연결 */ + } + peer.analyser?.disconnect(); + if (peer.audioElement) { + peer.audioElement.srcObject = null; + peer.audioElement.remove(); + } + peersRef.current.delete(memberId); + pendingIceRef.current.delete(memberId); + setSpeakingMemberIds((prev) => + prev.filter((id) => id !== String(memberId)), + ); + }, []); + + /** + * peer 연결과 로컬 트랙을 모두 정리한다. + * 마이크 on/off는 사용자 의도라 여기서 건드리지 않는다 (재연결 후에도 유지돼야 함). + */ + const disconnect = useCallback(() => { + Array.from(peersRef.current.keys()).forEach(closePeer); + pendingSignalsRef.current = []; + + localStreamRef.current?.getTracks().forEach((track) => { + track.stop(); + }); + localStreamRef.current = null; + localAnalyserRef.current?.disconnect(); + localAnalyserRef.current = null; + hasLocalMediaRef.current = false; + setHasLocalMedia(false); + }, [closePeer]); + + const manualRetry = useCallback(() => { + setErrorMessage(null); + setRetryAttempt(0); + setRetrySignal((n) => n + 1); + }, []); + + /** 원격 스트림을 숨은 audio 요소에 붙여 재생하고, 발화 감지를 위한 analyser를 건다. */ + const attachRemoteStream = useCallback( + (memberId: number, stream: MediaStream) => { + const peer = peersRef.current.get(memberId); + if (!peer) return; + + if (!peer.audioElement) { + const element = document.createElement("audio"); + element.autoplay = true; + element.style.display = "none"; + document.body.appendChild(element); + peer.audioElement = element; + } + peer.audioElement.srcObject = stream; + peer.audioElement.volume = isAudioOutputEnabledRef.current ? 1 : 0; + peer.audioElement.play().catch((error: unknown) => { + // 자동재생 차단은 사용자 제스처 이후 복구되므로 연결 실패로 취급하지 않는다. + console.error(`원격 오디오 자동재생 실패: memberId=${memberId}`, error); + }); + + const context = audioContextRef.current; + if (context && !peer.analyser) { + try { + const analyser = context.createAnalyser(); + analyser.fftSize = 512; + context.createMediaStreamSource(stream).connect(analyser); + peer.analyser = analyser; + } catch (error: unknown) { + // analyser 실패는 발화 표시만 못 하는 것이라 통화 자체는 계속 간다. + console.error(`발화 감지 연결 실패: memberId=${memberId}`, error); + } + } + }, + [], + ); + + const createPeer = useCallback( + (memberId: number): PeerEntry | null => { + const localStream = localStreamRef.current; + if (!localStream) return null; + + const connection = new RTCPeerConnection(RTC_CONFIG); + const peer: PeerEntry = { + connection, + audioElement: null, + analyser: null, + }; + peersRef.current.set(memberId, peer); + + localStream.getTracks().forEach((track) => { + connection.addTrack(track, localStream); + }); + + connection.onicecandidate = (event) => { + if (!event.candidate) return; + sendSignalRef.current("ice", memberId, { + candidate: event.candidate.toJSON(), + }); + }; + + connection.ontrack = (event) => { + const [stream] = event.streams; + if (stream) attachRemoteStream(memberId, stream); + }; + + connection.onconnectionstatechange = () => { + const state = connection.connectionState; + if (state === "failed" || state === "closed") { + console.error(`peer 연결 종료: memberId=${memberId}, state=${state}`); + } + }; + + return peer; + }, + [attachRemoteStream], + ); + + /** 버퍼링해 둔 ICE 후보를 remote description 설정 후에 흘려보낸다. */ + const flushPendingIce = useCallback(async (memberId: number) => { + const peer = peersRef.current.get(memberId); + const pending = pendingIceRef.current.get(memberId); + if (!peer || !pending) return; + + pendingIceRef.current.delete(memberId); + for (const candidate of pending) { + try { + await peer.connection.addIceCandidate(candidate); + } catch (error: unknown) { + console.error(`보류 ICE 추가 실패: memberId=${memberId}`, error); + } + } + }, []); + + const processSignal = useCallback( + async (frame: SignalFrame) => { + const { fromMemberId, payload } = frame; + try { + if (frame.type === "offer") { + if (!payload.sdp) return; + + // 상대가 새로고침 후 재입장하면 협상이 끝난 연결에 새 offer가 온다. + // roster의 퇴장 처리보다 먼저 도착할 수 있어, 여기서도 낡은 연결을 걷어낸다. + const existing = peersRef.current.get(fromMemberId); + if (existing?.connection.remoteDescription) { + closePeer(fromMemberId); + } + + // offer를 받았다는 건 상대가 initiator라는 뜻이므로 없으면 여기서 만든다. + const peer = + peersRef.current.get(fromMemberId) ?? createPeer(fromMemberId); + if (!peer) return; + + await peer.connection.setRemoteDescription(payload.sdp); + await flushPendingIce(fromMemberId); + const answer = await peer.connection.createAnswer(); + await peer.connection.setLocalDescription(answer); + sendSignalRef.current("answer", fromMemberId, { sdp: answer }); + return; + } + + if (frame.type === "answer") { + const peer = peersRef.current.get(fromMemberId); + if (!peer || !payload.sdp) return; + // 이미 stable이면 중복 answer이므로 흘린다. + if (peer.connection.signalingState !== "have-local-offer") return; + await peer.connection.setRemoteDescription(payload.sdp); + await flushPendingIce(fromMemberId); + return; + } + + if (frame.type === "ice") { + if (!payload.candidate) return; + const peer = peersRef.current.get(fromMemberId); + if (!peer || !peer.connection.remoteDescription) { + const pending = pendingIceRef.current.get(fromMemberId) ?? []; + pending.push(payload.candidate); + pendingIceRef.current.set(fromMemberId, pending); + return; + } + await peer.connection.addIceCandidate(payload.candidate); + } + } catch (error: unknown) { + console.error( + `시그널링 처리 실패: type=${frame.type}, from=${fromMemberId}`, + error, + ); + } + }, + [createPeer, flushPendingIce, closePeer], + ); + + // 1) 로컬 마이크 확보 + 오디오 컨텍스트 준비 + // biome-ignore lint/correctness/useExhaustiveDependencies: retrySignal은 manualRetry 트리거로 사용 + useEffect(() => { + if (meetingId == null || selfMemberId == null) return; + + let cancelled = false; + let attempt = 0; + let retryTimer: ReturnType | null = null; + + const acquire = async (): Promise => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + if (cancelled) { + stream.getTracks().forEach((track) => { + track.stop(); + }); + return; + } + + stream.getAudioTracks().forEach((track) => { + track.enabled = isMicEnabledRef.current; + }); + localStreamRef.current = stream; + + try { + const context = new AudioContext(); + audioContextRef.current = context; + // 자동재생 정책으로 suspended 상태일 수 있다. 실패해도 통화에는 영향이 없다. + await context.resume().catch(() => undefined); + const analyser = context.createAnalyser(); + analyser.fftSize = 512; + context.createMediaStreamSource(stream).connect(analyser); + localAnalyserRef.current = analyser; + } catch (error: unknown) { + console.error("발화 감지용 AudioContext 생성 실패", error); + } + + hasLocalMediaRef.current = true; + setHasLocalMedia(true); + setErrorMessage(null); + setRetryAttempt(0); + } catch (error: unknown) { + if (cancelled) return; + attempt += 1; + setRetryAttempt(attempt); + console.error( + `마이크 사용 실패 (${attempt}/${MAX_MEDIA_RETRIES})`, + error, + ); + + if (attempt < MAX_MEDIA_RETRIES) { + retryTimer = setTimeout(() => { + if (!cancelled) void acquire(); + }, MEDIA_RETRY_DELAY_MS); + return; + } + setErrorMessage( + error instanceof Error && error.name === "NotAllowedError" + ? "마이크 권한이 필요합니다." + : "마이크를 사용할 수 없습니다.", + ); + } + }; + + void acquire(); + + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + disconnect(); + audioContextRef.current?.close().catch(() => undefined); + audioContextRef.current = null; + setErrorMessage(null); + setRetryAttempt(0); + }; + }, [meetingId, selfMemberId, retrySignal, disconnect]); + + // 2) 시그널링 수신. 마이크가 아직이면 버퍼에 쌓아 두고 준비되면 처리한다. + useEffect(() => { + if (meetingId == null || selfMemberId == null) return; + + return subscribeSignal((frame) => { + // 본인이 보낸 프레임이 되돌아오는 경우 방어 + if (frame.fromMemberId === selfMemberIdRef.current) return; + if (!hasLocalMediaRef.current) { + pendingSignalsRef.current.push(frame); + return; + } + void processSignal(frame); + }); + }, [meetingId, selfMemberId, subscribeSignal, processSignal]); + + // 3) 마이크가 준비되면 밀린 시그널을 순서대로 흘려보낸다 + useEffect(() => { + if (!hasLocalMedia) return; + const buffered = pendingSignalsRef.current; + if (buffered.length === 0) return; + pendingSignalsRef.current = []; + + void (async () => { + for (const frame of buffered) { + await processSignal(frame); + } + })(); + }, [hasLocalMedia, processSignal]); + + // roster 배열은 갱신마다 새 참조라 정렬한 키로 좁혀서 effect 실행을 줄인다. + const peerKey = useMemo( + () => [...peerMemberIds].sort((a, b) => a - b).join(","), + [peerMemberIds], + ); + + // 4) roster 변화에 맞춰 peer 연결을 만들고 지운다 + useEffect(() => { + if (!hasLocalMedia || !isWsConnected) return; + const selfId = selfMemberIdRef.current; + if (selfId == null) return; + + const currentIds = peerKey + .split(",") + .filter(Boolean) + .map(Number) + .filter((id) => id !== selfId); + + Array.from(peersRef.current.keys()).forEach((existingId) => { + if (!currentIds.includes(existingId)) closePeer(existingId); + }); + + currentIds.forEach((peerId) => { + if (peersRef.current.has(peerId)) return; + const peer = createPeer(peerId); + if (!peer) return; + + // 양쪽이 동시에 offer를 걸지 않도록 id가 작은 쪽만 initiator가 된다. + if (selfId >= peerId) return; + + void (async () => { + try { + const offer = await peer.connection.createOffer(); + await peer.connection.setLocalDescription(offer); + sendSignalRef.current("offer", peerId, { sdp: offer }); + } catch (error: unknown) { + console.error(`offer 생성 실패: memberId=${peerId}`, error); + } + })(); + }); + }, [peerKey, hasLocalMedia, isWsConnected, createPeer, closePeer]); + + // 5) 발화 감지 — rAF 대신 저빈도 샘플링으로 리렌더를 억제한다 + useEffect(() => { + if (!hasLocalMedia) return; + + const buffer = new Uint8Array(256); + const lastSpokeAt = new Map(); + + const readRms = (analyser: AnalyserNode): number => { + analyser.getByteTimeDomainData(buffer); + let sumSquares = 0; + for (let i = 0; i < buffer.length; i += 1) { + const normalized = (buffer[i] - 128) / 128; + sumSquares += normalized * normalized; + } + return Math.sqrt(sumSquares / buffer.length); + }; + + const timer = setInterval(() => { + const now = Date.now(); + const selfId = selfMemberIdRef.current; + + if (localAnalyserRef.current && selfId != null) { + // 음소거 중에는 트랙이 무음이라 자연히 임계값 아래로 떨어진다. + if (readRms(localAnalyserRef.current) >= SPEAKING_RMS_THRESHOLD) { + lastSpokeAt.set(String(selfId), now); + } + } + + peersRef.current.forEach((peer, memberId) => { + if (!peer.analyser) return; + if (readRms(peer.analyser) >= SPEAKING_RMS_THRESHOLD) { + lastSpokeAt.set(String(memberId), now); + } + }); + + const speaking = Array.from(lastSpokeAt.entries()) + .filter(([, at]) => now - at <= SPEAKING_HOLD_MS) + .map(([id]) => id) + .sort(); + + // 값이 같으면 setState를 건너뛴다 (100ms마다 리렌더되는 것 방지) + setSpeakingMemberIds((prev) => + prev.length === speaking.length && + prev.every((id, index) => id === speaking[index]) + ? prev + : speaking, + ); + }, SPEAKING_SAMPLE_INTERVAL_MS); + + return () => { + clearInterval(timer); + }; + }, [hasLocalMedia]); + + return { + hasLocalMedia, + /** mesh는 SFU가 없어 "입장 완료" 기준이 로컬 미디어 확보다. peer 개별 실패는 화면을 막지 않는다. */ + isConnected: hasLocalMedia, + speakingMemberIds, + retryAttempt, + errorMessage, + isMicEnabled, + isAudioOutputEnabled, + setMicrophoneEnabled, + setAudioOutputEnabled, + manualRetry, + disconnect, + }; +}; diff --git a/web/src/pages/meeting/types.ts b/web/src/pages/meeting/types.ts index f9196e9..c1e1c67 100644 --- a/web/src/pages/meeting/types.ts +++ b/web/src/pages/meeting/types.ts @@ -3,6 +3,7 @@ export interface RoomParticipant { name: string; isSelf?: boolean; profileImage?: string | null; + isSpeaking?: boolean; } export interface TranscriptEntry { @@ -22,3 +23,17 @@ export interface InterimEntry { } export type OutgoingMessageType = "chat" | "audio_text" | "speech"; + +/** 서버가 targetMemberId 로 1:1 중계하는 WebRTC 시그널링 타입 */ +export type SignalMessageType = "offer" | "answer" | "ice"; + +export interface SignalPayload { + sdp?: RTCSessionDescriptionInit; + candidate?: RTCIceCandidateInit; +} + +export interface SignalFrame { + type: SignalMessageType; + fromMemberId: number; + payload: SignalPayload; +} diff --git a/web/src/types/meeting.ts b/web/src/types/meeting.ts index 18eff30..1af52ed 100644 --- a/web/src/types/meeting.ts +++ b/web/src/types/meeting.ts @@ -75,10 +75,3 @@ export interface MeetingHistory { participants: MeetingMember[]; dialogues: MeetingHistoryDialogue[]; } - -export interface RtcTokenResult { - meeting_id: number; - room_name: string; - server_url: string; - token: string; -} From e99d9c099918b5620d3939d508ebe1fc7bcc93c6 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Wed, 19 Aug 2026 11:28:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(server):=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=9A=8C=EC=9D=98=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EA=B3=BC=20LiveKit=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web의 WebRTC 전환(98ade11)으로 LiveKit Egress 녹음이 사라졌다. 오디오 대신 실시간 자막을 회의록 원본으로 삼도록 서버를 맞춘다. 자막 영속화 - MeetingDialogueCommandService: SPEECH 수신마다 Dialogue INSERT. 연관 엔티티는 프록시로만 잡아 발화당 쿼리 1회. 저장 실패는 회의를 끊지 않는다. - 지금까지 자막은 메모리에만 있어 서버 재시작에 회의록이 통째로 사라졌다. 분석 분기 - audioKey는 회의 생성 시점에 항상 채워져 분기 기준이 못 된다. MeetingAudioKeyResolver로 S3 실제 존재 여부를 보고 경로를 고른다. - 오디오 없으면 저장된 Dialogue를 전사 세그먼트로 만들어 meeting-analysis/extract 호출. extract 응답의 analysis_result는 전사 run과 스키마가 같아 저장 로직을 그대로 쓴다. - 오디오 준비 재시도(20회x3초)를 제거했다. 녹음이 없어 매번 60초를 버리던 대기다. - persistAnalysis에 replaceDialogues 플래그 추가. 자막 경로에서 false로 넘기지 않으면 orphanRemoval로 이미 저장한 회의록이 전부 삭제된다. LiveKit 제거 - LiveKitTokenService, LiveKitEgressClient, MeetingRtcService, rtc-token 엔드포인트, admin의 LiveKit room 관리 API, Egress 정지 로직, livekit 설정 블록 - Meeting.audioEgressId 필드와 @Setter 제거 + V2 마이그레이션으로 컬럼 DROP - 오디오 다시듣기 경로(S3/audio_key//audio)는 유지한다 Co-Authored-By: Claude Opus 5 --- docs/architecture.md | 25 +- docs/domain.md | 1 - server/AGENTS.md | 12 +- .../admin/controller/AdminController.java | 34 +- .../server/admin/dto/AdminResponse.java | 73 +-- .../service/AdminMeetingRoomService.java | 188 ++---- .../meeting/controller/MeetingController.java | 187 +++--- .../domain/meeting/dto/MeetingResponse.java | 77 +-- .../server/domain/meeting/entity/Meeting.java | 4 - .../repository/DialogueRepository.java | 11 + .../meeting/service/LiveKitTokenService.java | 82 --- .../service/MeetingAnalysisService.java | 570 +++++++++++------- .../service/MeetingCommandService.java | 101 ++-- .../MeetingDialogueCommandService.java | 34 ++ .../meeting/service/MeetingRtcService.java | 97 --- .../meeting/socket/MeetingSocketHandler.java | 182 +++--- .../socket/util/WebSocketTimeUtil.java | 28 +- .../fast/dto/request/LiveMessagePayload.java | 29 +- .../dto/request/TranscriptSegmentPayload.java | 20 + .../external/livekit/LiveKitEgressClient.java | 219 ------- server/src/main/resources/application.yaml | 5 - .../V2__drop_meeting_audio_egress_id.sql | 3 + server/src/main/resources/db/mock/mock.sql | 6 +- 23 files changed, 808 insertions(+), 1180 deletions(-) delete mode 100644 server/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java create mode 100644 server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java delete mode 100644 server/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java create mode 100644 server/src/main/java/com/whylog/server/global/external/fast/dto/request/TranscriptSegmentPayload.java delete mode 100644 server/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java create mode 100644 server/src/main/resources/db/migration/V2__drop_meeting_audio_egress_id.sql diff --git a/docs/architecture.md b/docs/architecture.md index f892eda..96d0cab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,19 +13,20 @@ Server (Spring Boot) ──REST──▶ ai (FastAPI) │ │ ├─ Gemini (텍스트 생성·임베딩) │ │ ├─ Deepgram (음성 전사) │ │ └─ ChromaDB (커밋·적용사항 임베딩) - │ └──REST──▶ LiveKit (실시간 오디오/비디오 SFU + 녹음 egress) └──REST──▶ GitHub (커밋 이력 조회, kohsuke GitHub API) └──S3 + +Web ◀──P2P WebRTC(오디오)──▶ Web (시그널링은 Server WebSocket이 중계) ``` -Web과 LiveKit은 예외적으로 직접 연결된다(RTC). 그 외 모든 통신은 Server를 거친다 — ai·GitHub·S3·LiveKit 토큰 발급은 Web이 직접 호출하지 않는다. +브라우저끼리의 오디오만 P2P로 직접 오간다. 그 외 모든 통신은 Server를 거친다 — ai·GitHub·S3를 Web이 직접 호출하지 않는다. ## 파트별 역할 | 파트 | 책임 | 책임이 아닌 것 | | --- | --- | --- | -| **Web** | 화면, 브라우저 Web Speech API로 실시간 자막 생성, LiveKit RTC 클라이언트 연결, Server REST/WebSocket 호출 | 원문 회의 분석, 커밋 매칭 점수 계산, 파일 저장 — 전부 Server·ai가 한다 | -| **Server** | 도메인 상태(Team/Meeting/Decision/Application/Commit)의 정본 저장, 외부 시스템(ai·LiveKit·GitHub·S3) 오케스트레이션, 인증 | 전사·요약·임베딩·매칭 점수 계산 자체는 하지 않는다 — 전부 ai에 위임하고 결과만 저장 | +| **Web** | 화면, 브라우저 Web Speech API로 실시간 자막 생성, P2P WebRTC mesh 연결과 발화 표시, Server REST/WebSocket 호출 | 원문 회의 분석, 커밋 매칭 점수 계산, 파일 저장 — 전부 Server·ai가 한다 | +| **Server** | 도메인 상태(Team/Meeting/Decision/Application/Commit)의 정본 저장, 외부 시스템(ai·GitHub·S3) 오케스트레이션, WebRTC 시그널링 중계, 인증 | 전사·요약·임베딩·매칭 점수 계산 자체는 하지 않는다 — 전부 ai에 위임하고 결과만 저장 | | **ai** | 음성 전사(Deepgram), 회의 분석·적용사항 추출(Gemini), 커밋 요약·임베딩(Gemini), 임베딩 유사도 기반 매칭 점수 계산(ChromaDB) | 도메인 데이터의 정본을 갖지 않는다 — 매 요청마다 필요한 컨텍스트를 Server가 실어 보낸다. 인증/인가도 하지 않는다 | ## 인증 @@ -34,18 +35,18 @@ Web과 LiveKit은 예외적으로 직접 연결된다(RTC). 그 외 모든 통 - Web은 access 토큰을 메모리에만 보관하고, 매 요청에 `Authorization: Bearer`로 주입한다. 401을 받으면 refresh로 재발급을 시도한다. - ai는 별도 인증이 없다 — Server만 호출한다는 전제로 신뢰 경계 안에 둔다. -## 서비스 흐름 1 — 실시간 회의 (Web ↔ LiveKit, Web ↔ Server) +## 서비스 흐름 1 — 실시간 회의 (Web ↔ Web, Web ↔ Server) -회의 중 오디오/비디오와 "실시간 텍스트"는 서로 다른 두 경로로 흐른다. +회의 중 오디오와 "실시간 텍스트"는 서로 다른 두 경로로 흐른다. -- **오디오/비디오**: Web은 Server가 발급한 토큰으로 LiveKit에 직접 연결해 RTC를 주고받는다. LiveKit이 room 전체 오디오를 녹음(egress)해 S3에 저장하고, 이 원본 오디오가 회의 종료 후 "흐름 2"의 공식 전사 입력이 된다. +- **오디오**: 브라우저끼리 P2P WebRTC mesh로 직접 주고받는다. offer/answer/ICE 시그널링만 Server의 회의 WebSocket이 1:1로 중계한다. **녹음은 하지 않는다** — 회의록의 원본은 아래 실시간 텍스트다. - **실시간 텍스트**: Web이 브라우저 Web Speech API로 발화를 즉석에서 전사하고, 그 결과를 Server가 여는 별도의 WebSocket으로 보낸다. Server는 이를 참가자에게 브로드캐스트하는 동시에 회의별로 버퍼링해둔다 — 화면 자막용이자, 회의 종료 후 공식 분석에 참고 컨텍스트로 함께 전달된다. -- 회의 종료(참여자가 직접 종료하거나, 실시간 참여자가 전부 빠져서 자동 종료)는 항상 같은 처리로 수렴한다: 녹음 중지 → 방 정리 → "흐름 2" 비동기 시작. +- 회의 종료(참여자가 직접 종료하거나, 실시간 참여자가 전부 빠져서 자동 종료)는 항상 같은 처리로 수렴한다: 방 정리 → "흐름 2" 비동기 시작. ## 서비스 흐름 2 — 회의 종료 후 AI 분석 (Server ↔ ai) -1. Server가 녹음된 오디오와 실시간 텍스트 버퍼를 ai에 넘겨 전사+분석 실행(run)을 비동기로 시작시킨다. -2. **ai**가 Deepgram으로 오디오를 전사하고, Gemini로 논의 주제·결정 근거·적용사항·타임라인을 추출한다. 진행 단계는 전사중 → 전사완료 → 요약완료 → 적용사항추출완료로 나뉘며, Server는 완료될 때까지 상태를 조회(폴링)한다. +1. Server가 저장된 발화(회의록)를 전사 세그먼트로 만들어 ai의 회의 분석 API에 넘긴다. 재생 가능한 오디오가 남아 있는 과거 회의만 예외적으로 Deepgram 전사 경로를 탄다. +2. **ai**가 Gemini로 논의 주제·결정 근거·적용사항·타임라인을 추출한다. 오디오 전사 경로일 때만 진행 단계(전사중 → 전사완료 → 요약완료 → 적용사항추출완료)를 Server가 폴링한다. 3. **Server**가 결과를 도메인 엔티티로 저장한다: 발화 목록, 회의 요약, 결정(Decision, 회의당 1개), 적용사항과 그 근거·타임라인. **적용사항 계열은 매번 전부 삭제 후 재생성한다 — 재분석은 append가 아니라 스냅샷 교체다.** 4. 저장이 끝나면 Server가 그 적용사항들을 다시 **ai**에 보내 임베딩을 만들게 하고(ChromaDB 저장), 이어서 커밋 자동 매칭을 요청한다. 이 두 단계는 실패해도 전체 흐름을 되돌리지 않는 best-effort다 — 분석 결과 저장 자체는 이미 끝났기 때문이다. 5. 매칭 점수 계산 로직은 **ai**에 있고(`docs/domain.md` 참조), Server는 결과를 신뢰도 임계값으로 한 번 더 걸러 저장할 뿐 점수 자체를 계산하지 않는다. @@ -65,7 +66,7 @@ AI 추천 매칭과 별개로, Web에서 사용자가 적용사항에 커밋을 ## 경계와 소유권 -- **ai를 직접 호출하는 곳은 Server 안의 한 경계 뿐이다.** 도메인 서비스가 ai SDK나 HTTP 호출을 직접 들고 있지 않고, 전용 클라이언트를 주입받아 쓴다. LiveKit·S3·GitHub 연동도 같은 원칙으로 별도 경계에 모여 있다. +- **ai를 직접 호출하는 곳은 Server 안의 한 경계 뿐이다.** 도메인 서비스가 ai SDK나 HTTP 호출을 직접 들고 있지 않고, 전용 클라이언트를 주입받아 쓴다. S3·GitHub 연동도 같은 원칙으로 별도 경계에 모여 있다. - **ai는 도메인 데이터의 정본을 갖지 않는다.** ChromaDB 임베딩은 매칭 계산용 파생 데이터일 뿐, 결정·적용사항·커밋 연결의 정본은 항상 Server DB다. - ai 쪽에서 프롬프트·매칭 가중치·ChromaDB 스키마를 바꾸면 Server가 파싱하는 응답 형태도 함께 깨질 수 있어 담당자 확인이 필요하다. -- 외부 호출(S3, ai, LiveKit, GitHub)은 Server의 트랜잭션 안에서 하지 않는다. 커밋 이후 실행이 필요하면 afterCommit 시점으로 미룬다. +- 외부 호출(S3, ai, GitHub)은 Server의 트랜잭션 안에서 하지 않는다. 커밋 이후 실행이 필요하면 afterCommit 시점으로 미룬다. diff --git a/docs/domain.md b/docs/domain.md index a4a1720..b324a6f 100644 --- a/docs/domain.md +++ b/docs/domain.md @@ -53,7 +53,6 @@ WhyLog는 팀 회의를 녹음·전사하고 AI로 논의 주제와 결정 근 - 실시간 참여자가 모두 빠지면(`meetingSocketRoomService`의 참여자 목록이 비면) 서버가 자동으로 회의를 종료 처리한다(`autoEndMeetingIfEmpty`). 이때는 참여자에게 종료를 브로드캐스트하지 않는다(정상 종료와 구분). - 회의 종료 시 `isNormallyEnded = true`로 세팅한다. 진행 시간(`getDuration`)은 `endDateTime`이 있는, 즉 종료된 회의에서만 계산 가능하다(진행중이면 null). - 회의가 끝나면 커밋 이후 비동기로 오디오 분석(`MeetingAnalysisService.analyzeMeetingAudio`)이 트리거된다. 분석 실패는 로그만 남기고 회의 종료 자체를 실패시키지 않는다. -- 회의 삭제 시 녹음 중이면(`audioEgressId` 존재) 먼저 LiveKit egress를 중지하고, 실패해도 로그만 남기고 삭제를 계속 진행한다(녹음 중지 실패가 데이터 정리를 막지 않는다). ### 팀 (Team) diff --git a/server/AGENTS.md b/server/AGENTS.md index 29fa6ae..341b3aa 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -30,7 +30,7 @@ - `XxxCommandService`는 클래스 레벨에 `@Transactional`을 붙입니다. - `XxxQueryService`는 클래스 레벨에 `@Transactional(readOnly = true)`를 붙입니다. - 같은 클래스 내부 호출은 프록시를 거치지 않으므로, 호출 대상 메서드에 별도로 선언한 `@Transactional` 속성(전파·격리·`readOnly` 등)이 적용되지 않습니다. 호출자가 연 트랜잭션은 유지됩니다. 별도 트랜잭션 경계가 필요하면 다른 빈으로 분리합니다. -- **외부 호출(S3, FastAPI, LiveKit, GitHub API)을 트랜잭션 안에서 하지 않습니다.** 커밋 이후 실행이 필요하면 `TransactionSynchronizationManager`로 afterCommit에 등록합니다. +- **외부 호출(S3, FastAPI, GitHub API)을 트랜잭션 안에서 하지 않습니다.** 커밋 이후 실행이 필요하면 `TransactionSynchronizationManager`로 afterCommit에 등록합니다. ```java // ❌ 트랜잭션 안에서 S3 업로드 — 업로드가 걸리는 시간만큼 DB 커넥션을 붙잡는다 @@ -71,13 +71,13 @@ scheduleAfterCommit(() -> s3Client.deleteFile(team.getImage())); ```java // ❌ 같은 prefix를 @Value로 나눠 주입 — 설정 하나 바꾸려면 어느 클래스를 봐야 하는지 알 수 없다 -@Value("${livekit.api-key}") private String apiKey; -@Value("${livekit.api-secret}") private String apiSecret; -@Value("${livekit.url}") private String url; +@Value("${cloud.aws.credentials.access-key}") private String accessKey; +@Value("${cloud.aws.credentials.secret-key}") private String secretKey; +@Value("${cloud.aws.region.static}") private String region; // ✅ 하나로 묶는다 -@ConfigurationProperties(prefix = "livekit") -public record LiveKitProperties(String apiKey, String apiSecret, String url) {} +@ConfigurationProperties(prefix = "cloud.aws") +public record AwsProperties(String accessKey, String secretKey, String region) {} ``` ### 코드 작성 공통 diff --git a/server/src/main/java/com/whylog/server/admin/controller/AdminController.java b/server/src/main/java/com/whylog/server/admin/controller/AdminController.java index a34b811..946b960 100644 --- a/server/src/main/java/com/whylog/server/admin/controller/AdminController.java +++ b/server/src/main/java/com/whylog/server/admin/controller/AdminController.java @@ -20,42 +20,18 @@ public class AdminController { private final AdminMeetingRoomService adminMeetingRoomService; - @GetMapping("/livekit/rooms") - @Operation(summary = "LiveKit 열린 room 목록 조회", description = "LiveKit에 현재 열려 있는 room 목록을 조회합니다.") - public ApiResponse listLiveKitRooms() { - return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitRooms()); - } - - @DeleteMapping("/livekit/rooms/{roomName}") - @Operation(summary = "LiveKit room 삭제", description = "지정한 LiveKit room을 삭제하고 연결된 참여자를 모두 종료합니다.") - public ApiResponse deleteLiveKitRoom( - @PathVariable String roomName - ) { - return ApiResponse.onSuccess(adminMeetingRoomService.deleteLiveKitRoom(roomName)); - } - - @GetMapping("/livekit/rooms/{roomName}/participants") - @Operation(summary = "LiveKit room 참여자 목록 조회", description = "지정한 LiveKit room에 현재 참여 중인 사용자 목록을 조회합니다.") - public ApiResponse listLiveKitParticipants( - @PathVariable String roomName - ) { - return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitParticipants(roomName)); - } - @DeleteMapping("/meeting-rooms/{meetingId}/participants/{memberId}") - @Operation(summary = "관리자용 참여자 강제 제거", description = "지정한 미팅룸에서 특정 참여자를 LiveKit room과 웹소켓 방에서 제거합니다.") + @Operation(summary = "관리자용 참여자 강제 제거", description = "지정한 미팅룸에서 특정 참여자를 웹소켓 방에서 제거합니다.") public ApiResponse removeParticipant( - @PathVariable Long meetingId, - @PathVariable Long memberId - ) { - return ApiResponse.onSuccess(adminMeetingRoomService.removeParticipant(meetingId, memberId)); + @PathVariable Long meetingId, @PathVariable Long memberId) { + return ApiResponse.onSuccess( + adminMeetingRoomService.removeParticipant(meetingId, memberId)); } @GetMapping("/meeting-rooms/{meetingId}/websocket-sessions") @Operation(summary = "회의 웹소켓 세션 정보 조회", description = "지정한 meetingId에 연결된 웹소켓 세션 정보를 조회합니다.") public ApiResponse listWebSocketSessions( - @PathVariable Long meetingId - ) { + @PathVariable Long meetingId) { return ApiResponse.onSuccess(adminMeetingRoomService.listWebSocketSessions(meetingId)); } } diff --git a/server/src/main/java/com/whylog/server/admin/dto/AdminResponse.java b/server/src/main/java/com/whylog/server/admin/dto/AdminResponse.java index efba38d..2865ac2 100644 --- a/server/src/main/java/com/whylog/server/admin/dto/AdminResponse.java +++ b/server/src/main/java/com/whylog/server/admin/dto/AdminResponse.java @@ -1,85 +1,20 @@ package com.whylog.server.admin.dto; import io.swagger.v3.oas.annotations.media.Schema; - import java.util.List; public class AdminResponse { - @Schema(description = "LiveKit 열린 room 목록 응답") - public record LiveKitRoomListDTO( - List rooms - ) { - } - - @Schema(description = "LiveKit room 정보") - public record LiveKitRoomDTO( - String sid, - String name, - Integer numParticipants, - Boolean activeRecording, - String creationTime, - String metadata - ) { - } - - @Schema(description = "LiveKit room 삭제 응답") - public record LiveKitRoomDeleteDTO( - String roomName, - Boolean deleted - ) { - } - - @Schema(description = "LiveKit room 참여자 목록 응답") - public record LiveKitParticipantListDTO( - String roomName, - List participants - ) { - } - - @Schema(description = "LiveKit room 참여자 정보") - public record LiveKitParticipantDTO( - String sid, - String identity, - String name, - String state, - String joinedAt, - Boolean isPublisher, - String metadata - ) { - } - @Schema(description = "관리자용 미팅 참여자 정보") - public record ParticipantDTO( - Long memberId, - String name, - String sessionId - ) { - } + public record ParticipantDTO(Long memberId, String name, String sessionId) {} @Schema(description = "회의 웹소켓 세션 목록 응답") - public record WebSocketSessionListDTO( - Long meetingId, - List sessions - ) { - } + public record WebSocketSessionListDTO(Long meetingId, List sessions) {} @Schema(description = "회의 웹소켓 세션 정보") - public record WebSocketSessionDTO( - String sessionId, - Long memberId, - String name, - Boolean open - ) { - } + public record WebSocketSessionDTO(String sessionId, Long memberId, String name, Boolean open) {} @Schema(description = "관리자용 참여자 강제 제거 응답") public record KickParticipantResponseDTO( - Long meetingId, - Long memberId, - Boolean removed, - Integer removedSessionCount, - Boolean liveKitRemoved - ) { - } + Long meetingId, Long memberId, Boolean removed, Integer removedSessionCount) {} } diff --git a/server/src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java b/server/src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java index 7fa0120..3ed2b4b 100644 --- a/server/src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java +++ b/server/src/main/java/com/whylog/server/admin/service/AdminMeetingRoomService.java @@ -1,107 +1,62 @@ package com.whylog.server.admin.service; import com.whylog.server.admin.dto.AdminResponse; -import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.meeting.service.MeetingCommandService; -import com.whylog.server.domain.meeting.service.LiveKitTokenService; import com.whylog.server.domain.meeting.socket.MeetingParticipant; import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; -import com.whylog.server.domain.meeting.socket.message.ParticipantLeftMessage; import com.whylog.server.domain.meeting.socket.message.MeetingMessageType; +import com.whylog.server.domain.meeting.socket.message.ParticipantLeftMessage; import com.whylog.server.domain.meeting.socket.message.ParticipantSummary; import com.whylog.server.domain.meeting.socket.message.RosterMessage; import com.whylog.server.domain.meeting.socket.repository.MeetingRoomRepository; import com.whylog.server.domain.meeting.socket.repository.MeetingSocketRoomRepository; -import com.whylog.server.global.external.livekit.LiveKitEgressClient; import com.whylog.server.global.util.json.JsonConverter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.socket.CloseStatus; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.Map; - @Slf4j @Service @RequiredArgsConstructor public class AdminMeetingRoomService { - private final MeetingRepository meetingRepository; private final MeetingSocketRoomRepository meetingSocketRoomRepository; private final MeetingSocketRoomService meetingSocketRoomService; private final MeetingCommandService meetingCommandService; - private final LiveKitTokenService liveKitTokenService; - private final LiveKitEgressClient liveKitEgressClient; - - @Transactional(readOnly = true) - public AdminResponse.LiveKitRoomListDTO listLiveKitRooms() { - String roomListToken = liveKitTokenService.createRoomListToken("room-admin"); - List rooms = liveKitEgressClient.listRooms(roomListToken).stream() - .map(this::toLiveKitRoomDTO) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(AdminResponse.LiveKitRoomDTO::name)) - .toList(); - - return new AdminResponse.LiveKitRoomListDTO(rooms); - } - - public AdminResponse.LiveKitRoomDeleteDTO deleteLiveKitRoom(String roomName) { - String roomCreateToken = liveKitTokenService.createRoomCreateToken("room-admin"); - liveKitEgressClient.deleteRoom(roomCreateToken, roomName); - return new AdminResponse.LiveKitRoomDeleteDTO(roomName, true); - } - - public AdminResponse.LiveKitParticipantListDTO listLiveKitParticipants(String roomName) { - String roomAdminToken = liveKitTokenService.createRoomAdminToken("room-admin", roomName); - List participants = liveKitEgressClient.listParticipants(roomAdminToken, roomName).stream() - .map(this::toLiveKitParticipantDTO) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(AdminResponse.LiveKitParticipantDTO::identity, Comparator.nullsLast(String::compareTo))) - .toList(); - - return new AdminResponse.LiveKitParticipantListDTO(roomName, participants); - } public AdminResponse.WebSocketSessionListDTO listWebSocketSessions(Long meetingId) { MeetingRoomRepository room = meetingSocketRoomRepository.findByMeetingId(meetingId); List sessions = new ArrayList<>(); if (room != null) { - sessions = room.participants().stream() - .map(this::toWebSocketSessionDTO) - .sorted(Comparator.comparing(AdminResponse.WebSocketSessionDTO::sessionId)) - .toList(); + sessions = + room.participants().stream() + .map(this::toWebSocketSessionDTO) + .sorted( + Comparator.comparing( + AdminResponse.WebSocketSessionDTO::sessionId)) + .toList(); } return new AdminResponse.WebSocketSessionListDTO(meetingId, sessions); } @Transactional - public AdminResponse.KickParticipantResponseDTO removeParticipant(Long meetingId, Long memberId) { + public AdminResponse.KickParticipantResponseDTO removeParticipant( + Long meetingId, Long memberId) { MeetingRoomRepository room = meetingSocketRoomRepository.findByMeetingId(meetingId); if (room == null) { - return new AdminResponse.KickParticipantResponseDTO(meetingId, memberId, false, 0, false); + return new AdminResponse.KickParticipantResponseDTO(meetingId, memberId, false, 0); } List removedParticipants = room.removeParticipantsByMemberId(memberId); if (removedParticipants.isEmpty()) { - return new AdminResponse.KickParticipantResponseDTO(meetingId, memberId, false, 0, false); - } - - boolean liveKitRemoved = false; - try { - String roomName = buildRoomName(meetingId); - String roomAdminToken = liveKitTokenService.createRoomAdminToken("room-admin", roomName); - liveKitEgressClient.removeParticipant(roomAdminToken, roomName, String.valueOf(memberId)); - liveKitRemoved = true; - } catch (RuntimeException exception) { - log.warn("LiveKit participant removal failed: meetingId={}, memberId={}", meetingId, memberId, exception); + return new AdminResponse.KickParticipantResponseDTO(meetingId, memberId, false, 0); } broadcastParticipantRemoval(meetingId, removedParticipants); @@ -113,55 +68,20 @@ public AdminResponse.KickParticipantResponseDTO removeParticipant(Long meetingId } return new AdminResponse.KickParticipantResponseDTO( - meetingId, - memberId, - true, - removedParticipants.size(), - liveKitRemoved - ); + meetingId, memberId, true, removedParticipants.size()); } - private AdminResponse.LiveKitRoomDTO toLiveKitRoomDTO(Map room) { - if (room == null) { - return null; - } - - return new AdminResponse.LiveKitRoomDTO( - stringValue(room.get("sid")), - stringValue(room.get("name")), - intValue(room.get("num_participants")), - booleanValue(room.get("active_recording")), - stringValue(room.get("creation_time")), - stringValue(room.get("metadata")) - ); - } - - private AdminResponse.LiveKitParticipantDTO toLiveKitParticipantDTO(Map participant) { - if (participant == null) { - return null; - } - - return new AdminResponse.LiveKitParticipantDTO( - stringValue(participant.get("sid")), - stringValue(participant.get("identity")), - stringValue(participant.get("name")), - stringValue(participant.get("state")), - stringValue(participant.get("joined_at")), - booleanValue(participant.get("is_publisher")), - stringValue(participant.get("metadata")) - ); - } - - private AdminResponse.WebSocketSessionDTO toWebSocketSessionDTO(MeetingParticipant participant) { + private AdminResponse.WebSocketSessionDTO toWebSocketSessionDTO( + MeetingParticipant participant) { return new AdminResponse.WebSocketSessionDTO( participant.sessionId(), participant.memberId(), participant.name(), - participant.socketSession() != null && participant.socketSession().isOpen() - ); + participant.socketSession() != null && participant.socketSession().isOpen()); } - private void broadcastParticipantRemoval(Long meetingId, List removedParticipants) { + private void broadcastParticipantRemoval( + Long meetingId, List removedParticipants) { if (removedParticipants.isEmpty()) { return; } @@ -169,21 +89,19 @@ private void broadcastParticipantRemoval(Long meetingId, List participants = meetingSocketRoomService.listParticipants(meetingId); + JsonConverter.toJson( + new ParticipantLeftMessage( + MeetingMessageType.PARTICIPANT_LEFT, + meetingId, + removedParticipant.memberId(), + removedParticipant.name(), + java.time.Instant.now().toString()))); + + List participants = + meetingSocketRoomService.listParticipants(meetingId); if (!participants.isEmpty()) { meetingSocketRoomService.broadcastText( - meetingId, - JsonConverter.toJson(RosterMessage.create(meetingId, participants)) - ); + meetingId, JsonConverter.toJson(RosterMessage.create(meetingId, participants))); } } @@ -194,41 +112,13 @@ private void closeRemovedSessions(List removedParticipants) participant.socketSession().close(CloseStatus.NORMAL); } } catch (IOException exception) { - log.warn("Failed to close kicked participant session: meetingId={}, memberId={}, sessionId={}", - participant.meetingId(), participant.memberId(), participant.sessionId(), exception); + log.warn( + "Failed to close kicked participant session: meetingId={}, memberId={}, sessionId={}", + participant.meetingId(), + participant.memberId(), + participant.sessionId(), + exception); } } } - - private String buildRoomName(Long meetingId) { - return "meeting-" + meetingId; - } - - private String stringValue(Object value) { - return value == null ? null : value.toString(); - } - - private Integer intValue(Object value) { - if (value instanceof Number number) { - return number.intValue(); - } - if (value == null) { - return null; - } - try { - return Integer.parseInt(value.toString()); - } catch (NumberFormatException exception) { - return null; - } - } - - private Boolean booleanValue(Object value) { - if (value instanceof Boolean bool) { - return bool; - } - if (value == null) { - return null; - } - return Boolean.parseBoolean(value.toString()); - } } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 160b3ec..06ba8cf 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -4,10 +4,9 @@ import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.meeting.exception.MeetingErrorCode; -import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.domain.meeting.service.MeetingAnalysisService; +import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.domain.meeting.service.MeetingQueryService; -import com.whylog.server.domain.meeting.service.MeetingRtcService; import com.whylog.server.domain.team.exception.TeamErrorCode; import com.whylog.server.domain.user.exception.MemberErrorStatus; import com.whylog.server.global.apiPayload.ApiResponse; @@ -19,6 +18,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +29,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; @RestController @RequestMapping("/api") @@ -39,26 +38,28 @@ public class MeetingController { private final MeetingCommandService meetingCommandService; private final MeetingQueryService meetingQueryService; - private final MeetingRtcService meetingRtcService; private final MeetingAnalysisService meetingAnalysisService; @GetMapping("/teams/{teamId}/meetings") - @Operation(summary = "회의 목록 조회 API", description = """ - + @Operation( + summary = "회의 목록 조회 API", + description = + """ + 특정 팀의 회의 목록을 조회하는 API입니다. status: ONGOING/COMPLETED - status는 필수가 아니며, 기본값은 COMPLETED 입니다. - + elapse : 경과시간 - 시:분:초 형태 - 완료된 회의라면 null로 반환함( 표시할 필요 없으니까 ) - + 페이징 없습니다. - + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") }) public ApiResponse> getMeetings( @Parameter(hidden = true) @CurrentMember Long memberId, @@ -68,117 +69,111 @@ public ApiResponse> getMeetings( return ApiResponse.onSuccess(meetingQueryService.getMeetings(teamId, status)); } -// @PostMapping("/meetings/{meetingId}/join") -// @Operation(summary = "회의 입장 API", description = "특정 회의에 입장하는 API입니다.") -// public ApiResponse joinMeeting( -// @PathVariable Long meetingId) { -// return ApiResponse.onSuccess(null); -// } + // @PostMapping("/meetings/{meetingId}/join") + // @Operation(summary = "회의 입장 API", description = "특정 회의에 입장하는 API입니다.") + // public ApiResponse joinMeeting( + // @PathVariable Long meetingId) { + // return ApiResponse.onSuccess(null); + // } @PostMapping("/teams/{teamId}/meetings") - @Operation(summary = "회의 생성 API", description = """ - + @Operation( + summary = "회의 생성 API", + description = + """ + 새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다. 해당 API는 방 생성만 담당합니다. 회의 참여를 위해서는 웹소켓 연결이 필요합니다. 웹소켓은 JWT 인증, 참여자 상태, WebRTC 시그널링 처리만 담당합니다. 실제 실시간 음성 전달은 WebRTC/SFU 경로를 사용합니다. - + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), - @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), + @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND") }) public ApiResponse createMeeting( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @Valid @RequestBody MeetingRequest.MeetingCreateDTO request) { - MeetingResponse.MeetingCreateResponseDTO result = meetingCommandService.makeMeetingRoom(memberId, teamId, request); + MeetingResponse.MeetingCreateResponseDTO result = + meetingCommandService.makeMeetingRoom(memberId, teamId, request); return ApiResponse.onSuccess(result); } - @GetMapping("/meetings/{meetingId}/rtc-token") - @Operation(summary = "회의 SFU 접속 토큰 발급 API", description = """ - 현재 로그인한 사용자가 해당 회의의 LiveKit SFU room에 접속할 수 있도록 join token을 발급합니다. - 프론트는 이 토큰과 serverUrl을 사용해 WebRTC 음성 연결을 수립합니다. - """) - @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND") - }) - public ApiResponse issueRtcToken( - @Parameter(hidden = true) @CurrentMember Long memberId, - @PathVariable Long meetingId - ) { - return ApiResponse.onSuccess(meetingRtcService.issueRtcToken(memberId, meetingId)); - } - @PatchMapping("/meetings/{meetingId}/end") - @Operation(summary = "회의 종료 API", description = """ + @Operation( + summary = "회의 종료 API", + description = + """ 진행 중인 회의를 종료하는 API입니다. 회의 종료 시 실시간 회의 참여자들에게 종료를 알리는 웹소켓 메시지를 전송합니다. 종료 이후의 음성 파일 처리, STT, 회의 분석은 비동기 후처리 파이프라인에서 수행합니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_INVALID_MEMBER"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_ALREADY_ENDED") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_INVALID_MEMBER"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_ALREADY_ENDED") }) public ApiResponse endMeeting( - @Parameter(hidden= true) @CurrentMember Long memberId, - @PathVariable Long meetingId) { + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingCommandService.endMeeting(memberId, meetingId)); } @PostMapping("/meetings/{meetingId}/analysis-test") - @Operation(summary = "회의 분석 저장 테스트 API", description = """ + @Operation( + summary = "회의 분석 저장 테스트 API", + description = + """ FastAPI 상태 조회 응답 JSON을 그대로 전달해 결정사항/적용사항 저장 로직만 테스트하는 API입니다. 외부 분석 호출 없이 저장 흐름만 검증할 때 사용합니다. """) public ApiResponse analyzeTestResponse( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId, - @RequestBody MeetingRequest.MeetingAnalysisTestDTO request - ) { + @RequestBody MeetingRequest.MeetingAnalysisTestDTO request) { meetingAnalysisService.persistTestMeetingAnalysis(memberId, meetingId, request); return ApiResponse.onSuccess(null); } @DeleteMapping("/meetings/{meetingId}") - @Operation(summary = "회의 삭제 API", description = """ + @Operation( + summary = "회의 삭제 API", + description = + """ 특정 회의를 삭제하는 API입니다. 회의 삭제 시 회의 참여자, 대화 기록, 결정사항, 분석 데이터가 함께 삭제됩니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_OWNER") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_OWNER") }) public ApiResponse deleteMeeting( - @Parameter(hidden = true) @CurrentMember Long memberId, - @PathVariable Long meetingId - ) { + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingCommandService.deleteMeeting(memberId, meetingId)); } @GetMapping("/meetings/{meetingId}") - @Operation(summary = "회의 기본 정보 조회 API", description = """ + @Operation( + summary = "회의 기본 정보 조회 API", + description = + """ 특정 회의의 기본 정보를 조회하는 API입니다. 회의명, 날짜, 기간, 참여자 정보를 제공합니다. 본 API에서 분석결과, 대화기록은 제공하지 않습니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") }) public ApiResponse getMeetingDetail( @PathVariable Long meetingId) { @@ -188,30 +183,32 @@ public ApiResponse getMeetingDetail( @GetMapping("/meetings/{meetingId}/history") @Operation(summary = "회의 대화 기록 조회 API", description = "특정 회의의 대화 기록을 조회하는 API입니다.") @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_END"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_UNNORMAL_END") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_END"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_UNNORMAL_END") }) - public ApiResponse getHistory( - @PathVariable Long meetingId) { + public ApiResponse getHistory(@PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingQueryService.getDialogueHistory(meetingId)); } @GetMapping("/meetings/{meetingId}/analysis") - @Operation(summary = "회의 분석 결과 조회 API", description = """ - + @Operation( + summary = "회의 분석 결과 조회 API", + description = + """ + 회의 분석 결과를 조회하는 API입니다.
회의가 존재하지 않으면 MEETING_NOT_FOUND(404)를 반환하고
회의가 존재하지만 아직 분석 중이거나 분석 결과를 만들지 못 헀을 경우에는
200응답으로 is_analyzed=false인 응답을 반환합니다.
- + """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") }) public ApiResponse getAnalysisResult( @PathVariable Long meetingId) { @@ -219,7 +216,10 @@ public ApiResponse getAnalysisResult( } @GetMapping("/meetings/{meetingId}/audio") - @Operation(summary = "오디오 리플레이 API", description = """ + @Operation( + summary = "오디오 리플레이 API", + description = + """ 회의 녹음본을 브라우저에서 바로 재생할 수 있는 정보를 조회하는 API입니다. 응답의 `audioUrl`은 10분짜리 presigned URL입니다. @@ -228,20 +228,19 @@ public ApiResponse getAnalysisResult( `audioDuration`은 초 단위 재생 시간이며, 녹음본이 없거나 길이를 확인할 수 없으면 `null`입니다. """) @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_AUDIO_NOT_READY") + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_AUDIO_NOT_READY") }) - public ApiResponse getAudio( - @PathVariable Long meetingId) { + public ApiResponse getAudio(@PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingQueryService.getMeetingAudio(meetingId)); } -// @GetMapping("/meetings/{meetingId}/applications") -// @Operation(summary = "적용사항 목록 조회 API", description = "특정 회의의 적용사항 목록을 조회하는 API입니다.") -// public ApiResponse> getApplications( -// @PathVariable Long meetingId) { -// return ApiResponse.onSuccess(null); -// } + // @GetMapping("/meetings/{meetingId}/applications") + // @Operation(summary = "적용사항 목록 조회 API", description = "특정 회의의 적용사항 목록을 조회하는 API입니다.") + // public ApiResponse> getApplications( + // @PathVariable Long meetingId) { + // return ApiResponse.onSuccess(null); + // } } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 8a78924..134c40e 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -2,14 +2,13 @@ import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.enums.MeetingStatus; -import com.whylog.server.domain.meeting.socket.MeetingParticipant; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; -import java.util.List; public class MeetingResponse { @@ -31,7 +30,6 @@ public static class MeetingListDTO { @Schema(description = "경과시간 (시:분:초)", example = "00:00:00") private String elapse; - } @Getter @@ -107,26 +105,6 @@ public static class MeetingDeleteResponseDTO { private Boolean isRemoved; } - @Getter - @NoArgsConstructor - @AllArgsConstructor - @Builder - @Schema(description = "회의 SFU 접속 토큰 응답") - public static class MeetingRtcTokenDTO { - - @Schema(description = "회의 ID", example = "1") - private Long meetingId; - - @Schema(description = "LiveKit room name", example = "meeting-1") - private String roomName; - - @Schema(description = "LiveKit server URL", example = "wss://livekit.example.com") - private String serverUrl; - - @Schema(description = "LiveKit join token") - private String token; - } - @Getter @NoArgsConstructor @AllArgsConstructor @@ -158,8 +136,7 @@ public static class MeetingDetailDTO { @Schema( description = "녹음 파일의 재생 시간(초). 녹음본이 아직 없거나 길이를 확인할 수 없으면 null 입니다.", example = "120", - nullable = true - ) + nullable = true) private Integer audioDuration; } @@ -189,9 +166,10 @@ public static class ParticipantDTO { @Schema(description = "참여자명", example = "김준용") private String name; - @Schema(description = "참여자 프로필 사진 URL", example = "https://example.com/profile/user-1.jpg") + @Schema( + description = "참여자 프로필 사진 URL", + example = "https://example.com/profile/user-1.jpg") private String profileImage; - } @Getter @@ -209,9 +187,7 @@ public static class DialogueDTO { @Schema(description = "말한 시간", example = "00:22") private String timestamp; - } - } @Getter @@ -239,23 +215,32 @@ public static class AnalysisResultDTO { @Schema(description = "회의 재생 시간(초)", example = "43", nullable = true) private Integer audioDuration; - @Schema(description = "논의 주제 목록", example = "[\"서버 저장 상태 확인\", \"배포 DB 환경\"]", nullable = true) + @Schema( + description = "논의 주제 목록", + example = "[\"서버 저장 상태 확인\", \"배포 DB 환경\"]", + nullable = true) private List topics; - @Schema(description = "핵심 맥락 목록", example = "[\"서버에 데이터가 저장되지 않은 상태로 추정됨\"]", nullable = true) + @Schema( + description = "핵심 맥락 목록", + example = "[\"서버에 데이터가 저장되지 않은 상태로 추정됨\"]", + nullable = true) private List coreContext; - @Schema(description = "적용사항 제목 목록", example = "[\"서버 저장 절차 정리\", \"배포 DB 점검\"]", nullable = true) + @Schema( + description = "적용사항 제목 목록", + example = "[\"서버 저장 절차 정리\", \"배포 DB 점검\"]", + nullable = true) private List applicationTitles; - @Schema(description = "적용사항 사유 목록", example = "[\"저장 실패 원인을 추적하기 위해\", \"배포 환경 차이를 확인하기 위해\"]", nullable = true) + @Schema( + description = "적용사항 사유 목록", + example = "[\"저장 실패 원인을 추적하기 위해\", \"배포 환경 차이를 확인하기 위해\"]", + nullable = true) private List applicationReasons; public static AnalysisResultDTO createFalse(Long meetingId) { - return AnalysisResultDTO.builder() - .meetingId(meetingId) - .isAnalyzed(false) - .build(); + return AnalysisResultDTO.builder().meetingId(meetingId).isAnalyzed(false).build(); } public static AnalysisResultDTO create(MeetingAnalysis ma, Integer audioDuration) { @@ -272,7 +257,6 @@ public static AnalysisResultDTO create(MeetingAnalysis ma, Integer audioDuration .applicationReasons(ma.getApplicationReasons()) .build(); } - } @Getter @@ -284,20 +268,21 @@ public static class AudioDTO { @Schema(description = "회의 ID", example = "1") private Long meetingId; - @Schema(description = "실제 재생 가능한 오디오 저장 키(S3 object key). 예: recordings/meeting-1-audio.mp4", example = "recordings/meeting-1-audio.mp4") + @Schema( + description = + "실제 재생 가능한 오디오 저장 키(S3 object key). 예: recordings/meeting-1-audio.mp4", + example = "recordings/meeting-1-audio.mp4") private String audioKey; @Schema( description = "10분짜리 presigned URL. 브라우저는 이 URL로 오디오를 직접 받아 재생합니다.", - example = "https://example.com/presigned-audio-url" - ) + example = "https://example.com/presigned-audio-url") private String audioUrl; @Schema( description = "녹음 파일의 재생 시간(초). 아직 파일이 없거나 길이를 알 수 없으면 null 입니다.", example = "120", - nullable = true - ) + nullable = true) private Integer audioDuration; } @@ -320,7 +305,7 @@ public static class ApplicationDTO { @AllArgsConstructor @Builder @Schema(description = "미팅 내 참여자 정보") - public static class MeetingParticipantInfo{ + public static class MeetingParticipantInfo { @Schema(description = "멤버 id", example = "1") private Long memberId; @@ -330,7 +315,5 @@ public static class MeetingParticipantInfo{ @Schema(description = "프로필이미지", example = "https://example.com/profile/user-1.jpg") private String profileImage; - } - } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/server/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index 028e967..b042c83 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -55,10 +55,6 @@ public class Meeting extends BaseEntity { @Column(name = "audio_key", length = 255) private String audioKey; - @Setter - @Column(name = "audio_egress_id", length = 100) - private String audioEgressId; - @Column(name = "is_normally_ended", nullable = false) private Boolean isNormallyEnded; diff --git a/server/src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java b/server/src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java index e5af2f9..b25dff1 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.meeting.repository; import com.whylog.server.domain.meeting.entity.Dialogue; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -8,6 +9,16 @@ public interface DialogueRepository extends JpaRepository { + /** + * 회의록을 발화 순서대로 조회합니다. 같은 초에 들어온 발화는 speech_datetime만으로 순서가 정해지지 않으므로 삽입 순서와 일치하는 dialogue_id로 + * 순서를 확정합니다. 화자 이름이 필요해 member를 함께 가져옵니다. + */ + @Query( + "SELECT d FROM Dialogue d JOIN FETCH d.member" + + " WHERE d.meeting.id = :meetingId" + + " ORDER BY d.speechDateTime, d.id") + List findAllByMeetingIdOrderBySpeechTime(@Param("meetingId") Long meetingId); + @Modifying @Query("DELETE FROM Dialogue d WHERE d.meeting.team.id = :teamId") void deleteByTeamId(@Param("teamId") Long teamId); diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java deleted file mode 100644 index f19a23c..0000000 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/LiveKitTokenService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import jakarta.validation.constraints.NotBlank; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.Map; - -@Service -public class LiveKitTokenService { - - private final String liveKitApiKey; - private final String liveKitApiSecret; - - public LiveKitTokenService( - @Value("${livekit.api-key}") @NotBlank String liveKitApiKey, - @Value("${livekit.api-secret}") @NotBlank String liveKitApiSecret - ) { - this.liveKitApiKey = liveKitApiKey; - this.liveKitApiSecret = liveKitApiSecret; - } - - public String createJoinToken(String identity, String name, String roomName) { - Map videoGrant = new LinkedHashMap<>(); - videoGrant.put("roomJoin", true); - videoGrant.put("room", roomName); - videoGrant.put("canPublish", true); - videoGrant.put("canSubscribe", true); - videoGrant.put("canPublishData", true); - - return createToken(identity, name, videoGrant); - } - - public String createRoomRecordToken(String identity, String roomName) { - Map videoGrant = new LinkedHashMap<>(); - videoGrant.put("roomRecord", true); - videoGrant.put("room", roomName); - - return createToken(identity, "recording-bot", videoGrant); - } - - public String createRoomCreateToken(String identity) { - Map videoGrant = new LinkedHashMap<>(); - videoGrant.put("roomCreate", true); - - return createToken(identity, "room-admin", videoGrant); - } - - public String createRoomListToken(String identity) { - Map videoGrant = new LinkedHashMap<>(); - videoGrant.put("roomList", true); - - return createToken(identity, "room-admin", videoGrant); - } - - public String createRoomAdminToken(String identity, String roomName) { - Map videoGrant = new LinkedHashMap<>(); - videoGrant.put("roomAdmin", true); - videoGrant.put("room", roomName); - - return createToken(identity, "room-admin", videoGrant); - } - - private String createToken(String identity, String name, Map videoGrant) { - return Jwts.builder() - .setIssuer(liveKitApiKey) - .setSubject(identity) - .claim("name", name) - .claim("video", videoGrant) - .signWith(getSigningKey()) - .compact(); - } - - private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(liveKitApiSecret.getBytes(StandardCharsets.UTF_8)); - } -} diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index cca9c13..18abd5c 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.meeting.service; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.whylog.server.domain.decision.entity.Application; import com.whylog.server.domain.decision.entity.ApplicationBase; @@ -9,11 +10,11 @@ import com.whylog.server.domain.decision.entity.DecisionBase; import com.whylog.server.domain.decision.entity.DecisionTimeline; import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; -import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionBaseRepository; -import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.decision.service.DecisionCommitMatchService; import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Dialogue; @@ -21,29 +22,32 @@ import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; -import com.whylog.server.domain.meeting.exception.MeetingAudioNotReadyException; +import com.whylog.server.domain.meeting.repository.DialogueRepository; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; +import com.whylog.server.domain.meeting.socket.util.WebSocketTimeUtil; import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import com.whylog.server.global.apiPayload.exception.GeneralException; import com.whylog.server.global.external.fast.client.FastApiMeetingAnalysisClient; +import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; import com.whylog.server.global.external.fast.dto.FastApiResponse; import com.whylog.server.global.external.fast.dto.request.ApplicationEmbeddingsRequest; +import com.whylog.server.global.external.fast.dto.request.MeetingAnalysisRequest; +import com.whylog.server.global.external.fast.dto.request.TranscriptSegmentPayload; import com.whylog.server.global.external.fast.dto.response.ApplicationEmbeddingsResponse; import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunCreateResponse; import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; import com.whylog.server.global.external.fast.exception.FastApiErrorCode; import com.whylog.server.global.external.fast.exception.FastApiException; -import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; -import com.whylog.server.global.apiPayload.exception.GeneralException; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Map; import java.util.List; -import java.util.function.Function; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -59,13 +63,13 @@ @RequiredArgsConstructor public class MeetingAnalysisService { - private static final Duration AUDIO_READY_WAIT_INTERVAL = Duration.ofSeconds(3); - private static final int MAX_AUDIO_READY_ATTEMPTS = 20; private static final Duration RUN_POLL_WAIT_INTERVAL = Duration.ofSeconds(3); private static final int MAX_RUN_POLL_ATTEMPTS = 120; private final MeetingUseCase meetingUseCase; private final MeetingAudioReplayService meetingAudioReplayService; + private final MeetingAudioKeyResolver meetingAudioKeyResolver; + private final DialogueRepository dialogueRepository; private final MeetingAudioFileService meetingAudioFileService; private final FastApiTranscribeClient fastApiTranscribeClient; private final FastApiMeetingAnalysisClient fastApiMeetingAnalysisClient; @@ -82,20 +86,104 @@ public class MeetingAnalysisService { private final TransactionTemplate transactionTemplate; private final ObjectMapper objectMapper; - // 회의 종료 후 오디오 분석 전체 흐름을 시작한다. ( 외부 클래스에서 호출 ) + /** + * 회의 종료 후 분석 전체 흐름을 시작한다. ( 외부 클래스에서 호출 ) + * + *

실시간 회의가 WebRTC로 바뀌면서 녹음이 사라졌다. 재생 가능한 오디오가 있는 회의(과거 데이터)는 기존 전사 경로를 그대로 타고, 없으면 실시간 자막만으로 + * 분석한다. audioKey는 회의 생성 시점에 항상 채워지므로 분기 기준이 될 수 없어, S3에 실제 파일이 있는지로 판단한다. + */ public void analyzeMeetingAudio(Long meetingId) { Meeting meeting = meetingUseCase.findMeetingWithMembersById(meetingId); - MeetingResponse.AudioDTO audioResponse = resolveAudioWithRetry(meeting); + + if (meetingAudioKeyResolver.resolvePlayableAudioKey(meeting).isPresent()) { + analyzeWithAudio(meeting); + return; + } + analyzeWithTranscript(meeting); + } + + // 오디오가 있는 회의를 전사부터 돌린다. + private void analyzeWithAudio(Meeting meeting) { + MeetingResponse.AudioDTO audioResponse = + meetingAudioReplayService.buildAudioResponse(meeting); String runId = createTranscribeApplicationRun(meeting, audioResponse); TranscribeApplicationRunResponse finalResponse = pollTranscribeApplicationRun(runId); persistMeetingAnalysis(meeting, finalResponse); } + /** 저장된 실시간 자막만으로 회의를 분석한다. 자막은 이미 Dialogue로 저장돼 있으므로 분석 결과로 덮어쓰지 않는다. */ + private void analyzeWithTranscript(Meeting meeting) { + List dialogues = + dialogueRepository.findAllByMeetingIdOrderBySpeechTime(meeting.getId()); + if (dialogues.isEmpty()) { + log.warn("실시간 자막이 없어 회의 분석을 건너뛴다: meetingId={}", meeting.getId()); + return; + } + + List segments = buildTranscriptSegments(meeting, dialogues); + MeetingAnalysisRequest request = + new MeetingAnalysisRequest( + String.valueOf(meeting.getId()), null, objectMapper.valueToTree(segments)); + + FastApiResponse response = + fastApiMeetingAnalysisClient.extractMeetingAnalysis(request); + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = + readAnalysisResult(requireResult(response)); + + log.info("자막 기반 회의 분석 완료: meetingId={}, segmentCount={}", meeting.getId(), segments.size()); + persistAnalysis(meeting, analysisResult, List.of(), false); + } + + /** + * Dialogue를 FastAPI 전사 세그먼트 형식으로 되돌린다. speaker와 isFinal은 FastAPI 필수 필드라 비우면 422가 난다. 발화 종료 시각은 + * 저장하지 않으므로 다음 발화의 시작 시각으로 대신한다. + */ + private List buildTranscriptSegments( + Meeting meeting, List dialogues) { + LocalDateTime meetingStart = meeting.getStartDateTime(); + List startTimes = + dialogues.stream() + .map( + dialogue -> + WebSocketTimeUtil.formatElapsed( + meetingStart, dialogue.getSpeechDateTime())) + .toList(); + + List segments = new ArrayList<>(); + for (int index = 0; index < dialogues.size(); index++) { + Dialogue dialogue = dialogues.get(index); + String startTime = startTimes.get(index); + String endTime = index + 1 < startTimes.size() ? startTimes.get(index + 1) : startTime; + segments.add( + new TranscriptSegmentPayload( + (long) (index + 1), + dialogue.getMember().getName(), + dialogue.getMember().getId(), + startTime, + endTime, + dialogue.getContent(), + true)); + } + return segments; + } + + // 재추출 응답에서 분석 결과만 꺼낸다. 전사 세그먼트는 응답에 포함되지 않는다. + private TranscribeApplicationRunResponse.AnalysisResultResponse readAnalysisResult( + JsonNode result) { + JsonNode analysisResult = result.get("analysis_result"); + if (analysisResult == null || analysisResult.isNull()) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + return objectMapper.convertValue( + analysisResult, TranscribeApplicationRunResponse.AnalysisResultResponse.class); + } + // FastAPI 응답 JSON을 받아 저장 로직을 테스트한다. - public void persistTestMeetingAnalysis(Long memberId, - Long meetingId, - com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingAnalysisTestDTO request) { + public void persistTestMeetingAnalysis( + Long memberId, + Long meetingId, + com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingAnalysisTestDTO request) { if (!meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) { throw new MeetingInvalidMemberException(); } @@ -109,42 +197,24 @@ public void persistTestMeetingAnalysis(Long memberId, persistMeetingAnalysis(meeting, response); } - // 회의 오디오가 준비될 때까지 재시도하며 오디오 응답을 확보한다. - private MeetingResponse.AudioDTO resolveAudioWithRetry(Meeting meeting) { - MeetingAudioNotReadyException lastException = null; - - for (int attempt = 1; attempt <= MAX_AUDIO_READY_ATTEMPTS; attempt++) { - try { - return meetingAudioReplayService.buildAudioResponse(meeting); - } catch (MeetingAudioNotReadyException e) { - lastException = e; - sleep(AUDIO_READY_WAIT_INTERVAL); - } - } - - if (lastException != null) { - throw lastException; - } - throw new MeetingAudioNotReadyException(); - } - // FastAPI 전사용 비동기 실행(run)을 생성하고 runId를 반환한다. - private String createTranscribeApplicationRun(Meeting meeting, MeetingResponse.AudioDTO audioResponse) { + private String createTranscribeApplicationRun( + Meeting meeting, MeetingResponse.AudioDTO audioResponse) { String audioKey = audioResponse.getAudioKey(); String audioUrl = audioResponse.getAudioUrl(); String audioFilename = meetingAudioFileService.extractFileName(audioKey); String contentType = meetingAudioFileService.resolveResponseContentType(audioKey); String liveMessagesJson = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); - FastApiResponse createResponse = fastApiTranscribeClient.createTranscribeApplicationRun( - buildAudioResource(audioUrl), - audioFilename, - contentType, - null, - String.valueOf(meeting.getId()), - null, - liveMessagesJson - ); + FastApiResponse createResponse = + fastApiTranscribeClient.createTranscribeApplicationRun( + buildAudioResource(audioUrl), + audioFilename, + contentType, + null, + String.valueOf(meeting.getId()), + null, + liveMessagesJson); String runId = requireResult(createResponse).runId(); log.info("회의 오디오 분석 run 생성 완료: meetingId={}, runId={}", meeting.getId(), runId); @@ -157,7 +227,8 @@ private TranscribeApplicationRunResponse pollTranscribeApplicationRun(String run for (int attempt = 1; attempt <= MAX_RUN_POLL_ATTEMPTS; attempt++) { try { - FastApiResponse response = fastApiTranscribeClient.getTranscribeApplicationRun(runId); + FastApiResponse response = + fastApiTranscribeClient.getTranscribeApplicationRun(runId); TranscribeApplicationRunResponse runResponse = requireResult(response); if (isFailed(runResponse)) { @@ -192,7 +263,8 @@ private boolean isCompleted(TranscribeApplicationRunResponse response) { // run이 실패 상태인지 확인한다. private boolean isFailed(TranscribeApplicationRunResponse response) { - return "failed".equalsIgnoreCase(response.status()) || "failed".equalsIgnoreCase(response.phase()); + return "failed".equalsIgnoreCase(response.status()) + || "failed".equalsIgnoreCase(response.phase()); } // FastAPI run not found가 일시적 상태인지 판별한다. @@ -200,11 +272,13 @@ private boolean isTransientRunNotFound(FastApiException exception) { Throwable cause = exception.getCause(); while (cause != null) { if (cause instanceof HttpClientErrorException httpClientErrorException - && httpClientErrorException.getStatusCode().value() == HttpStatus.NOT_FOUND.value()) { + && httpClientErrorException.getStatusCode().value() + == HttpStatus.NOT_FOUND.value()) { return true; } if (cause instanceof RestClientResponseException restClientResponseException - && restClientResponseException.getStatusCode().value() == HttpStatus.NOT_FOUND.value()) { + && restClientResponseException.getStatusCode().value() + == HttpStatus.NOT_FOUND.value()) { return true; } cause = cause.getCause(); @@ -213,87 +287,137 @@ private boolean isTransientRunNotFound(FastApiException exception) { } // FastAPI 분석 결과를 회의 도메인 엔티티와 대화 목록으로 저장한다. - private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunResponse response) { - TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = response.result(); + private void persistMeetingAnalysis( + Meeting meeting, TranscribeApplicationRunResponse response) { + TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = + response.result(); if (runResult == null) { throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); } List transcriptSegments = Optional.ofNullable(runResult.transcriptSegments()).orElseGet(List::of); - TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = runResult.analysisResult(); + persistAnalysis(meeting, runResult.analysisResult(), transcriptSegments, true); + } + + /** + * 분석 결과를 회의 도메인 엔티티로 저장한다. + * + *

{@code replaceDialogues}가 true면 회의록을 전사 세그먼트로 통째로 교체한다. 자막 기반 경로는 이미 실시간으로 저장해 둔 Dialogue가 + * 원본이므로 false로 넘겨야 한다. true로 넘기면서 세그먼트가 비어 있으면 orphanRemoval로 기존 회의록이 전부 삭제된다. + */ + private void persistAnalysis( + Meeting meeting, + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult, + List transcriptSegments, + boolean replaceDialogues) { TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = analysisResult != null ? analysisResult.overallAnalysis() : null; List applications = - analysisResult != null && analysisResult.applications() != null ? analysisResult.applications() : List.of(); - MeetingAnalysis.MeetingAnalysisPayload payload = buildMeetingAnalysisPayload(overallAnalysis); - - SavedApplications savedApplications = transactionTemplate.execute(status -> { - Meeting managedMeeting = meetingUseCase.findMeetingWithMembersById(meeting.getId()); - MeetingAnalysis meetingAnalysis = meetingAnalysisRepository.findByMeetingId(managedMeeting.getId()) - .map(existingMeetingAnalysis -> { - existingMeetingAnalysis.updateAnalysis(payload); - return existingMeetingAnalysis; - }) - .orElseGet(() -> MeetingAnalysis.create(managedMeeting, payload)); - meetingAnalysisRepository.save(meetingAnalysis); - managedMeeting.attachMeetingAnalysis(meetingAnalysis); - - List dialogues = buildDialogues(managedMeeting, transcriptSegments); - managedMeeting.getDialogues().clear(); - managedMeeting.getDialogues().addAll(dialogues); - - Decision decision = createDecisionIfAbsent(managedMeeting); - return replaceApplications(managedMeeting.getId(), decision, applications); - }); + analysisResult != null && analysisResult.applications() != null + ? analysisResult.applications() + : List.of(); + MeetingAnalysis.MeetingAnalysisPayload payload = + buildMeetingAnalysisPayload(overallAnalysis); + + SavedApplications savedApplications = + transactionTemplate.execute( + status -> { + Meeting managedMeeting = + meetingUseCase.findMeetingWithMembersById(meeting.getId()); + MeetingAnalysis meetingAnalysis = + meetingAnalysisRepository + .findByMeetingId(managedMeeting.getId()) + .map( + existingMeetingAnalysis -> { + existingMeetingAnalysis.updateAnalysis( + payload); + return existingMeetingAnalysis; + }) + .orElseGet( + () -> + MeetingAnalysis.create( + managedMeeting, payload)); + meetingAnalysisRepository.save(meetingAnalysis); + managedMeeting.attachMeetingAnalysis(meetingAnalysis); + + if (replaceDialogues) { + List dialogues = + buildDialogues(managedMeeting, transcriptSegments); + managedMeeting.getDialogues().clear(); + managedMeeting.getDialogues().addAll(dialogues); + } + + Decision decision = createDecisionIfAbsent(managedMeeting); + return replaceApplications( + managedMeeting.getId(), decision, applications); + }); if (savedApplications == null) { savedApplications = SavedApplications.empty(null); } - log.info("회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", meeting.getId(), transcriptSegments.size()); - sendApplicationEmbeddingsSafely(meeting, response, savedApplications); + log.info( + "회의 분석 저장 완료: meetingId={}, transcriptSegmentCount={}, replaceDialogues={}", + meeting.getId(), + transcriptSegments.size(), + replaceDialogues); + sendApplicationEmbeddingsSafely(meeting, analysisResult, savedApplications); matchApplicationCommitsSafely(meeting, savedApplications); - } // Decision이 없을 때 새로 생성한다. private Decision createDecisionIfAbsent(Meeting meeting) { - return decisionRepository.findByMeetingId(meeting.getId()) - .orElseGet(() -> { - Decision decision = decisionRepository.save(Decision.create(meeting, true)); - log.info("결정사항 저장 완료: meetingId={}, decisionId={}", meeting.getId(), decision.getId()); - return decision; - }); + return decisionRepository + .findByMeetingId(meeting.getId()) + .orElseGet( + () -> { + Decision decision = + decisionRepository.save(Decision.create(meeting, true)); + log.info( + "결정사항 저장 완료: meetingId={}, decisionId={}", + meeting.getId(), + decision.getId()); + return decision; + }); } // 분석 결과의 적용사항 제목 목록을 저장한다. - private SavedApplications replaceApplications(Long meetingId, - Decision decision, - List applications) { + private SavedApplications replaceApplications( + Long meetingId, + Decision decision, + List applications) { applicationBaseRepository.deleteByMeetingId(meetingId); applicationTimelineRepository.deleteByMeetingId(meetingId); decisionBaseRepository.deleteByMeetingId(meetingId); decisionTimelineRepository.deleteByMeetingId(meetingId); applicationRepository.deleteByMeetingId(meetingId); - List validApplications = applications.stream() - .filter(application -> application != null - && application.applicationTitle() != null - && !application.applicationTitle().isBlank()) - .toList(); - - List newApplications = validApplications.stream() - .map(application -> Application.create( - decision, - application.applicationTitle().trim() - )) - .toList(); + List validApplications = + applications.stream() + .filter( + application -> + application != null + && application.applicationTitle() != null + && !application.applicationTitle().isBlank()) + .toList(); + + List newApplications = + validApplications.stream() + .map( + application -> + Application.create( + decision, application.applicationTitle().trim())) + .toList(); if (!newApplications.isEmpty()) { - List savedApplications = applicationRepository.saveAllAndFlush(newApplications); + List savedApplications = + applicationRepository.saveAllAndFlush(newApplications); persistApplicationDetails(savedApplications, validApplications); - log.info("적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", - meetingId, decision.getId(), newApplications.size()); + log.info( + "적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", + meetingId, + decision.getId(), + newApplications.size()); return new SavedApplications(decision.getId(), savedApplications, validApplications); } @@ -301,11 +425,13 @@ private SavedApplications replaceApplications(Long meetingId, } // 저장된 적용사항 엔티티에 reason/timeline 세부 정보를 순서대로 연결 저장한다. - private void persistApplicationDetails(List applications, - List applicationResponses) { + private void persistApplicationDetails( + List applications, + List applicationResponses) { for (int index = 0; index < applications.size(); index++) { Application application = applications.get(index); - TranscribeApplicationRunResponse.ApplicationResponse response = applicationResponses.get(index); + TranscribeApplicationRunResponse.ApplicationResponse response = + applicationResponses.get(index); persistApplicationReasons(application, response.applicationReasons()); persistApplicationTimelines(application, response.timeline()); } @@ -318,141 +444,174 @@ private void persistApplicationReasons(Application application, List rea return; } - List decisionBases = validReasons.stream() - .map(reason -> DecisionBase.create(application.getDecision(), reason.trim())) - .toList(); - List savedDecisionBases = decisionBaseRepository.saveAllAndFlush(decisionBases); - - List applicationBases = savedDecisionBases.stream() - .map(decisionBase -> ApplicationBase.create(application, decisionBase)) - .toList(); + List decisionBases = + validReasons.stream() + .map( + reason -> + DecisionBase.create( + application.getDecision(), reason.trim())) + .toList(); + List savedDecisionBases = + decisionBaseRepository.saveAllAndFlush(decisionBases); + + List applicationBases = + savedDecisionBases.stream() + .map(decisionBase -> ApplicationBase.create(application, decisionBase)) + .toList(); applicationBaseRepository.saveAllAndFlush(applicationBases); } // 적용사항 timeline 목록을 DecisionTimeline/ApplicationTimeline으로 분리 저장한다. - private void persistApplicationTimelines(Application application, - List timelines) { + private void persistApplicationTimelines( + Application application, + List timelines) { if (timelines == null || timelines.isEmpty()) { return; } - List decisionTimelines = timelines.stream() - .filter(timeline -> timeline != null) - .map(timeline -> DecisionTimeline.create( - application.getDecision(), - timeline.timestamp(), - timeline.step(), - timeline.content(), - timeline.memberId(), - timeline.utterance() - )) - .toList(); + List decisionTimelines = + timelines.stream() + .filter(timeline -> timeline != null) + .map( + timeline -> + DecisionTimeline.create( + application.getDecision(), + timeline.timestamp(), + timeline.step(), + timeline.content(), + timeline.memberId(), + timeline.utterance())) + .toList(); if (decisionTimelines.isEmpty()) { return; } - List savedDecisionTimelines = decisionTimelineRepository.saveAllAndFlush(decisionTimelines); - List applicationTimelines = savedDecisionTimelines.stream() - .map(decisionTimeline -> ApplicationTimeline.create(application, decisionTimeline)) - .toList(); + List savedDecisionTimelines = + decisionTimelineRepository.saveAllAndFlush(decisionTimelines); + List applicationTimelines = + savedDecisionTimelines.stream() + .map( + decisionTimeline -> + ApplicationTimeline.create(application, decisionTimeline)) + .toList(); applicationTimelineRepository.saveAllAndFlush(applicationTimelines); } // 저장된 적용사항을 FastAPI 임베딩 요청으로 전달한다. - private void sendApplicationEmbeddingsSafely(Meeting meeting, - TranscribeApplicationRunResponse runResponse, - SavedApplications savedApplications) { + private void sendApplicationEmbeddingsSafely( + Meeting meeting, + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult, + SavedApplications savedApplications) { if (savedApplications.savedApplications().isEmpty()) { log.info("저장된 적용사항이 없어 임베딩 호출을 생략한다: meetingId={}", meeting.getId()); return; } try { - ApplicationEmbeddingsRequest request = buildEmbeddingsRequest(meeting, runResponse, savedApplications); - FastApiResponse response = fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); - Integer totalDocuments = response != null && response.result() != null ? response.result().totalDocuments() : null; - log.info("적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", meeting.getId(), totalDocuments); + ApplicationEmbeddingsRequest request = + buildEmbeddingsRequest(meeting, analysisResult, savedApplications); + FastApiResponse response = + fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); + Integer totalDocuments = + response != null && response.result() != null + ? response.result().totalDocuments() + : null; + log.info( + "적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", + meeting.getId(), + totalDocuments); } catch (Exception exception) { log.error("적용사항 임베딩 호출 실패: meetingId={}", meeting.getId(), exception); } } // 회의 분석 저장 후 적용사항-커밋 추천 매칭을 자동 실행한다. - private void matchApplicationCommitsSafely(Meeting meeting, SavedApplications savedApplications) { - if (savedApplications.decisionId() == null || savedApplications.savedApplications().isEmpty()) { + private void matchApplicationCommitsSafely( + Meeting meeting, SavedApplications savedApplications) { + if (savedApplications.decisionId() == null + || savedApplications.savedApplications().isEmpty()) { log.info("저장된 적용사항이 없어 커밋 추천 매칭을 생략한다: meetingId={}", meeting.getId()); return; } try { decisionCommitMatchService.matchApplicationCommits(savedApplications.decisionId()); - log.info("적용사항-커밋 추천 매칭 완료: meetingId={}, decisionId={}", - meeting.getId(), savedApplications.decisionId()); + log.info( + "적용사항-커밋 추천 매칭 완료: meetingId={}, decisionId={}", + meeting.getId(), + savedApplications.decisionId()); } catch (GeneralException exception) { ErrorReasonDTO reason = exception.getErrorReason(); - log.error("적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}, errorCode={}, message={}", - meeting.getId(), savedApplications.decisionId(), reason.getCode(), reason.getMessage(), exception); + log.error( + "적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}, errorCode={}, message={}", + meeting.getId(), + savedApplications.decisionId(), + reason.getCode(), + reason.getMessage(), + exception); } catch (Exception exception) { - log.error("적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}", - meeting.getId(), savedApplications.decisionId(), exception); + log.error( + "적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}", + meeting.getId(), + savedApplications.decisionId(), + exception); } } // FastAPI 임베딩 요청을 생성한다. - private ApplicationEmbeddingsRequest buildEmbeddingsRequest(Meeting meeting, - TranscribeApplicationRunResponse runResponse, - SavedApplications savedApplications) { - TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = runResponse.result(); - TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = - runResult != null ? runResult.analysisResult() : null; + private ApplicationEmbeddingsRequest buildEmbeddingsRequest( + Meeting meeting, + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult, + SavedApplications savedApplications) { TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = analysisResult != null ? analysisResult.overallAnalysis() : null; List otherMentions = - analysisResult != null && analysisResult.otherMentions() != null ? analysisResult.otherMentions() : List.of(); + analysisResult != null && analysisResult.otherMentions() != null + ? analysisResult.otherMentions() + : List.of(); - List applicationPayloads = new ArrayList<>(); + List applicationPayloads = + new ArrayList<>(); List applications = savedApplications.savedApplications(); - List sourceApplications = savedApplications.sourceApplications(); + List sourceApplications = + savedApplications.sourceApplications(); for (int index = 0; index < applications.size(); index++) { Application savedApplication = applications.get(index); - TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = sourceApplications.get(index); - applicationPayloads.add(new ApplicationEmbeddingsRequest.ApplicationPayload( - savedApplication.getId(), - sourceApplication.applicationTitle(), - sourceApplication.applicationReasons(), - mapTimelinePayloads(sourceApplication.timeline()) - )); + TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = + sourceApplications.get(index); + applicationPayloads.add( + new ApplicationEmbeddingsRequest.ApplicationPayload( + savedApplication.getId(), + sourceApplication.applicationTitle(), + sourceApplication.applicationReasons(), + mapTimelinePayloads(sourceApplication.timeline()))); } return new ApplicationEmbeddingsRequest( String.valueOf(meeting.getId()), null, new ApplicationEmbeddingsRequest.AnalysisResultPayload( - overallAnalysis, - applicationPayloads, - otherMentions - ) - ); + overallAnalysis, applicationPayloads, otherMentions)); } // FastAPI 응답 타임라인을 임베딩 요청 payload로 변환한다. private List mapTimelinePayloads( - List timelines - ) { + List timelines) { if (timelines == null || timelines.isEmpty()) { return List.of(); } return timelines.stream() .filter(timeline -> timeline != null) - .map(timeline -> new ApplicationEmbeddingsRequest.TimelinePayload( - timeline.timestamp(), - timeline.step(), - timeline.memberId(), - timeline.content(), - timeline.utterance() - )) + .map( + timeline -> + new ApplicationEmbeddingsRequest.TimelinePayload( + timeline.timestamp(), + timeline.step(), + timeline.memberId(), + timeline.content(), + timeline.utterance())) .toList(); } @@ -462,33 +621,30 @@ private List safeStrings(List values) { return List.of(); } - return values.stream() - .filter(value -> value != null && !value.isBlank()) - .toList(); + return values.stream().filter(value -> value != null && !value.isBlank()).toList(); } - // OverallAnalysis를 MeetingAnalysis 저장용 payload로 변환한다. private MeetingAnalysis.MeetingAnalysisPayload buildMeetingAnalysisPayload( - TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis - ) { + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { if (overallAnalysis == null) { return new MeetingAnalysis.MeetingAnalysisPayload( - null, - null, - null, - null, - null, - null, - null, - null - ); + null, null, null, null, null, null, null, null); } String analysisContent = serializeOverallAnalysis(overallAnalysis); - String meetingTitle = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().title() : null; - String meetingPurpose = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().purpose() : null; - String meetingDuration = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().duration() : null; + String meetingTitle = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().title() + : null; + String meetingPurpose = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().purpose() + : null; + String meetingDuration = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().duration() + : null; List topics = overallAnalysis.topics(); List coreContext = overallAnalysis.coreContext(); List applicationTitles = overallAnalysis.applicationTitles(); @@ -502,12 +658,12 @@ private MeetingAnalysis.MeetingAnalysisPayload buildMeetingAnalysisPayload( topics, coreContext, applicationTitles, - applicationReasons - ); + applicationReasons); } // OverallAnalysis를 원본 JSON 문자열로 직렬화한다. - private String serializeOverallAnalysis(TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { + private String serializeOverallAnalysis( + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { if (overallAnalysis == null) { return null; } @@ -521,15 +677,17 @@ private String serializeOverallAnalysis(TranscribeApplicationRunResponse.Overall } // 전사 세그먼트를 회의 참여자와 매칭해 Dialogue 목록으로 변환한다. - private List buildDialogues(Meeting meeting, - List transcriptSegments) { + private List buildDialogues( + Meeting meeting, + List transcriptSegments) { if (transcriptSegments == null || transcriptSegments.isEmpty()) { return List.of(); } - Map membersById = meeting.getMeetingMembers().stream() - .map(MeetingMember::getMember) - .collect(Collectors.toMap(Member::getId, Function.identity())); + Map membersById = + meeting.getMeetingMembers().stream() + .map(MeetingMember::getMember) + .collect(Collectors.toMap(Member::getId, Function.identity())); if (membersById.isEmpty()) { return List.of(); @@ -537,7 +695,8 @@ private List buildDialogues(Meeting meeting, List dialogues = new ArrayList<>(); for (int index = 0; index < transcriptSegments.size(); index++) { - TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = transcriptSegments.get(index); + TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = + transcriptSegments.get(index); if (segment == null || segment.text() == null || segment.text().isBlank()) { continue; } @@ -547,7 +706,8 @@ private List buildDialogues(Meeting meeting, continue; } - LocalDateTime speechDateTime = resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); + LocalDateTime speechDateTime = + resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); dialogues.add(Dialogue.create(meeting, member, segment.text().trim(), speechDateTime)); } @@ -555,7 +715,8 @@ private List buildDialogues(Meeting meeting, } // 전사 시작 시각 offset을 회의 시작 시각 기준 LocalDateTime으로 변환한다. - private LocalDateTime resolveSpeechDateTime(LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { + private LocalDateTime resolveSpeechDateTime( + LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { if (meetingStartDateTime == null) { return LocalDateTime.now(); } @@ -591,9 +752,10 @@ private Duration parseDuration(String offset) { } } - private record SavedApplications(Long decisionId, - List savedApplications, - List sourceApplications) { + private record SavedApplications( + Long decisionId, + List savedApplications, + List sourceApplications) { private static SavedApplications empty(Long decisionId) { return new SavedApplications(decisionId, List.of(), List.of()); diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index 5ba3f86..a96cb67 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -5,33 +5,28 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.enums.MeetingRole; -import com.whylog.server.domain.meeting.exception.MeetingErrorCode; import com.whylog.server.domain.meeting.exception.MeetingAlreadyEndedException; +import com.whylog.server.domain.meeting.exception.MeetingErrorCode; import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; -import com.whylog.server.domain.meeting.service.MeetingAnalysisService; -import com.whylog.server.domain.meeting.service.MeetingCleanupService; -import com.whylog.server.domain.meeting.service.LiveKitTokenService; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.domain.team.service.TeamUseCase; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; -import com.whylog.server.global.external.livekit.LiveKitEgressClient; -import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import java.time.LocalDateTime; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; -import lombok.extern.slf4j.Slf4j; - -import java.time.LocalDateTime; @Service @Slf4j @@ -44,8 +39,6 @@ public class MeetingCommandService { private final MeetingAudioFileService meetingAudioFileService; private final MeetingAnalysisService meetingAnalysisService; private final MeetingLiveMessageRepository meetingLiveMessageRepository; - private final LiveKitTokenService liveKitTokenService; - private final LiveKitEgressClient liveKitEgressClient; private final MemberUseCase memberUseCase; private final TeamUseCase teamUseCase; @@ -53,18 +46,19 @@ public class MeetingCommandService { private final MeetingSocketRoomService meetingSocketRoomService; /* - 회의를 생성합니다. + 회의를 생성합니다. - 회의를 생성하여 endDateTime이 null인 회의를 저장합니다. - - endDateTime == null : 진행 중인 회의를 의미합니다. + 회의를 생성하여 endDateTime이 null인 회의를 저장합니다. + - endDateTime == null : 진행 중인 회의를 의미합니다. - 회의와 회의참여자 정보가 함께 저장됩니다. - */ + 회의와 회의참여자 정보가 함께 저장됩니다. + */ @Transactional - public MeetingResponse.MeetingCreateResponseDTO makeMeetingRoom(Long memberId, Long teamId, MeetingRequest.MeetingCreateDTO requestDTO){ + public MeetingResponse.MeetingCreateResponseDTO makeMeetingRoom( + Long memberId, Long teamId, MeetingRequest.MeetingCreateDTO requestDTO) { // null 체크 - if(teamId == null) throw new ParameterRequiredException(); + if (teamId == null) throw new ParameterRequiredException(); // API 호출한 유저 정보 조회 Member member = memberUseCase.findMemberById(memberId); @@ -93,19 +87,20 @@ public MeetingResponse.MeetingCreateResponseDTO makeMeetingRoom(Long memberId, L } /* - 회의를 종료합니다. - - 실시간 회의 참여자들에게 회의 종료를 메시지를 보냅니다. - - 실시간 데이터로 다루는 실시간 회의 정보도 제거합니다. - */ + 회의를 종료합니다. + - 실시간 회의 참여자들에게 회의 종료를 메시지를 보냅니다. + - 실시간 데이터로 다루는 실시간 회의 정보도 제거합니다. + */ @Transactional public MeetingResponse.MeetingEndResponseDTO endMeeting(Long memberId, Long meetingId) { // 조회 및 검증 - Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(MeetingNotFoundException::new); + Meeting meeting = + meetingRepository.findById(meetingId).orElseThrow(MeetingNotFoundException::new); - if(!meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) // 회의 참여자 존재 검증 - throw new MeetingInvalidMemberException(); + if (!meetingMemberRepository.existsByMemberIdAndMeetingId( + memberId, meetingId)) // 회의 참여자 존재 검증 + throw new MeetingInvalidMemberException(); if (!meeting.isOngoing()) { // 이미 종료된 회의인지 검증 throw new MeetingAlreadyEndedException(); @@ -130,13 +125,13 @@ public void autoEndMeetingIfEmpty(Long meetingId) { @Transactional public MeetingResponse.MeetingDeleteResponseDTO deleteMeeting(Long memberId, Long meetingId) { - Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(MeetingNotFoundException::new); + Meeting meeting = + meetingRepository.findById(meetingId).orElseThrow(MeetingNotFoundException::new); - meetingMemberRepository.findOwnerMeetingMember(memberId, meetingId, MeetingRole.OWNER) + meetingMemberRepository + .findOwnerMeetingMember(memberId, meetingId, MeetingRole.OWNER) .orElseThrow(() -> new ErrorHandler(MeetingErrorCode.MEETING_NOT_OWNER)); - stopRecordingForDelete(meeting); meetingCleanupService.deleteByMeetingId(meetingId); meetingLiveMessageRepository.clear(meetingId); scheduleAfterCommit(() -> meetingSocketRoomService.closeRoom(meetingId)); @@ -149,38 +144,21 @@ public MeetingResponse.MeetingDeleteResponseDTO deleteMeeting(Long memberId, Lon private void scheduleAfterCommit(Runnable task) { if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - task.run(); - } - }); + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); return; } task.run(); } - private void stopRecording(Meeting meeting) { - if (meeting == null || meeting.getAudioEgressId() == null || meeting.getAudioEgressId().isBlank()) { - return; - } - - String roomName = "meeting-" + meeting.getId(); - String egressToken = liveKitTokenService.createRoomRecordToken("recording-" + meeting.getId(), roomName); - liveKitEgressClient.stopEgress(egressToken, meeting.getAudioEgressId()); - } - - private void stopRecordingForDelete(Meeting meeting) { - try { - stopRecording(meeting); - } catch (Exception exception) { - log.warn("Failed to stop meeting recording before delete: meetingId={}, egressId={}", - meeting.getId(), meeting.getAudioEgressId(), exception); - } - } - - private MeetingResponse.MeetingEndResponseDTO finishMeeting(Meeting meeting, boolean broadcastEnded) { + private MeetingResponse.MeetingEndResponseDTO finishMeeting( + Meeting meeting, boolean broadcastEnded) { LocalDateTime endDateTime = meeting.endMeeting(); meetingRepository.save(meeting); @@ -188,7 +166,6 @@ private MeetingResponse.MeetingEndResponseDTO finishMeeting(Meeting meeting, boo meetingSocketRoomService.broadcastMeetingEnded(meeting.getId(), endDateTime); } - stopRecording(meeting); meetingSocketRoomService.closeRoom(meeting.getId()); scheduleAfterCommit(() -> analyzeMeetingAudioAsync(meeting.getId())); @@ -201,10 +178,10 @@ private MeetingResponse.MeetingEndResponseDTO finishMeeting(Meeting meeting, boo private void analyzeMeetingAudioAsync(Long meetingId) { CompletableFuture.runAsync(() -> meetingAnalysisService.analyzeMeetingAudio(meetingId)) - .exceptionally(ex -> { - log.error("회의 오디오 분석 실패: meetingId={}", meetingId, ex); - return null; - }); + .exceptionally( + ex -> { + log.error("회의 오디오 분석 실패: meetingId={}", meetingId, ex); + return null; + }); } - } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java new file mode 100644 index 0000000..6e368b7 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java @@ -0,0 +1,34 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.domain.meeting.entity.Dialogue; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.repository.DialogueRepository; +import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.repository.MemberRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// 실시간 발화를 회의록으로 즉시 저장합니다. 오디오 녹음이 없어져 이 자막이 회의록의 유일한 원본입니다. +@Service +@Transactional +@RequiredArgsConstructor +public class MeetingDialogueCommandService { + + private final DialogueRepository dialogueRepository; + private final MeetingRepository meetingRepository; + private final MemberRepository memberRepository; + + /** + * 발화 한 건을 저장합니다. 발화마다 호출되므로 연관 엔티티는 프록시로만 잡아 INSERT 한 번으로 끝냅니다. meetingId·memberId는 웹소켓 핸드셰이크에서 + * 검증된 값이라 존재 여부를 다시 조회하지 않습니다. + */ + public void appendSpeech( + Long meetingId, Long memberId, String text, LocalDateTime speechDateTime) { + Meeting meeting = meetingRepository.getReferenceById(meetingId); + Member member = memberRepository.getReferenceById(memberId); + dialogueRepository.save(Dialogue.create(meeting, member, text, speechDateTime)); + } +} diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java deleted file mode 100644 index 8771df8..0000000 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -import com.whylog.server.domain.meeting.dto.MeetingResponse; -import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.entity.MeetingMember; -import com.whylog.server.domain.meeting.enums.MeetingRole; -import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; -import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; -import com.whylog.server.domain.meeting.repository.MeetingRepository; -import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.domain.user.service.MemberUseCase; -import com.whylog.server.global.external.livekit.LiveKitEgressClient; -import jakarta.validation.constraints.NotBlank; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -public class MeetingRtcService { - - private final MeetingRepository meetingRepository; - private final MeetingMemberRepository meetingMemberRepository; - private final MemberUseCase memberUseCase; - private final MeetingAudioFileService meetingAudioFileService; - private final LiveKitTokenService liveKitTokenService; - private final LiveKitEgressClient liveKitEgressClient; - private final String liveKitUrl; - - public MeetingRtcService( - MeetingRepository meetingRepository, - MeetingMemberRepository meetingMemberRepository, - MemberUseCase memberUseCase, - MeetingAudioFileService meetingAudioFileService, - LiveKitTokenService liveKitTokenService, - LiveKitEgressClient liveKitEgressClient, - @Value("${livekit.url}") @NotBlank String liveKitUrl - ) { - this.meetingRepository = meetingRepository; - this.meetingMemberRepository = meetingMemberRepository; - this.memberUseCase = memberUseCase; - this.meetingAudioFileService = meetingAudioFileService; - this.liveKitTokenService = liveKitTokenService; - this.liveKitEgressClient = liveKitEgressClient; - this.liveKitUrl = liveKitUrl; - } - - @Transactional - public MeetingResponse.MeetingRtcTokenDTO issueRtcToken(Long memberId, Long meetingId) { - Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(MeetingNotFoundException::new); - - Member member = memberUseCase.findMemberById(memberId); - ensureMeetingParticipant(meeting, member); - String roomName = buildRoomName(meeting); - startRecordingIfAbsent(meeting, roomName); - String identity = String.valueOf(member.getId()); - String token = liveKitTokenService.createJoinToken(identity, member.getName(), roomName); - - return MeetingResponse.MeetingRtcTokenDTO.builder() - .meetingId(meetingId) - .roomName(roomName) - .serverUrl(liveKitUrl) - .token(token) - .build(); - } - - private void ensureMeetingParticipant(Meeting meeting, Member member) { - if (meetingMemberRepository.existsByMemberIdAndMeetingId(member.getId(), meeting.getId())) { - return; - } - meetingMemberRepository.save(MeetingMember.create(meeting, member, MeetingRole.GENERAL)); - } - - private String buildRoomName(Meeting meeting) { - return "meeting-" + meeting.getId(); - } - - private void startRecordingIfAbsent(Meeting meeting, String roomName) { - if (meeting.getAudioEgressId() != null && !meeting.getAudioEgressId().isBlank()) { - return; - } - - String audioKey = meeting.getAudioKey(); - if (audioKey == null || audioKey.isBlank()) { - audioKey = meetingAudioFileService.buildRecordingKey(meeting.getId()); - meeting.setAudioKey(audioKey); - } - - String roomAdminToken = liveKitTokenService.createRoomCreateToken("room-admin"); - liveKitEgressClient.createRoom(roomAdminToken, roomName); - - String egressToken = liveKitTokenService.createRoomRecordToken("recording-" + meeting.getId(), roomName); - String egressId = liveKitEgressClient.startRoomAudioEgress(egressToken, roomName, audioKey); - meeting.setAudioEgressId(egressId); - meetingRepository.save(meeting); - } -} diff --git a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java index 22dfc32..cf7c51f 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java @@ -1,10 +1,24 @@ package com.whylog.server.domain.meeting.socket; import com.fasterxml.jackson.core.JsonProcessingException; -import com.whylog.server.domain.meeting.socket.message.*; import com.whylog.server.domain.meeting.service.MeetingCommandService; +import com.whylog.server.domain.meeting.service.MeetingDialogueCommandService; +import com.whylog.server.domain.meeting.socket.message.ConnectedMessage; +import com.whylog.server.domain.meeting.socket.message.ErrorMessage; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import com.whylog.server.domain.meeting.socket.message.MeetingMessageType; +import com.whylog.server.domain.meeting.socket.message.MeetingSocketMessage; +import com.whylog.server.domain.meeting.socket.message.MeetingTextMessage; +import com.whylog.server.domain.meeting.socket.message.ParticipantJoinedMessage; +import com.whylog.server.domain.meeting.socket.message.ParticipantLeftMessage; +import com.whylog.server.domain.meeting.socket.message.ParticipantSummary; +import com.whylog.server.domain.meeting.socket.message.RosterMessage; import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; import com.whylog.server.global.util.json.JsonConverter; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; @@ -14,13 +28,8 @@ import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; import org.springframework.web.socket.handler.BinaryWebSocketHandler; - -import java.time.LocalDateTime; -import java.time.Instant; -import java.util.List; -import java.util.Optional; +import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; // 회의 웹소켓 연결의 입장, 퇴장, 텍스트 메시지, 오디오 바이너리 중계를 처리합니다. @Component @@ -34,6 +43,7 @@ public class MeetingSocketHandler extends BinaryWebSocketHandler { private final MeetingSocketRoomService meetingSocketRoomService; private final MeetingCommandService meetingCommandService; private final MeetingLiveMessageRepository meetingLiveMessageRepository; + private final MeetingDialogueCommandService meetingDialogueCommandService; // 웹소켓 연결 직후 참가자를 방에 등록하고 현재 참여자 목록과 입장 이벤트를 전파합니다. @Override @@ -44,29 +54,34 @@ public void afterConnectionEstablished(@NonNull WebSocketSession session) throws return; } - if (meetingSocketRoomService.existsParticipant(participant.meetingId(), participant.memberId())) { - sendError(session, MeetingMessageType.PARTICIPANT_ALREADY_JOINED, "이미 실시간으로 참여 중인 회의입니다."); + if (meetingSocketRoomService.existsParticipant( + participant.meetingId(), participant.memberId())) { + sendError( + session, + MeetingMessageType.PARTICIPANT_ALREADY_JOINED, + "이미 실시간으로 참여 중인 회의입니다."); session.close(CloseStatus.NORMAL); return; } meetingSocketRoomService.join(participant); - List currentParticipants = participantSummaries(participant.meetingId()); - ConnectedMessage connectedMessage = ConnectedMessage.create( - participant, currentParticipants - ); + List currentParticipants = + participantSummaries(participant.meetingId()); + ConnectedMessage connectedMessage = + ConnectedMessage.create(participant, currentParticipants); session.sendMessage(new TextMessage(JsonConverter.toJson(connectedMessage))); broadcastRoster(participant.meetingId(), currentParticipants); meetingSocketRoomService.broadcastText( participant.meetingId(), - new TextMessage(JsonConverter.toJson(ParticipantJoinedMessage.create(participant))) - ); + new TextMessage( + JsonConverter.toJson(ParticipantJoinedMessage.create(participant)))); } // 채팅, 자막, 시그널링 같은 텍스트 기반 회의 메시지를 파싱해 브로드캐스트 또는 단건 전달합니다. @Override - protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) { + protected void handleTextMessage( + @NonNull WebSocketSession session, @NonNull TextMessage message) { MeetingParticipant participant = createParticipant(session); MeetingSocketMessage incoming; try { @@ -91,19 +106,24 @@ protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull Tex // 실시간 오디오는 WebRTC/SFU 경로로 전달하고, 웹소켓 바이너리 프레임은 더 이상 중계하지 않습니다. @Override - protected void handleBinaryMessage(@NonNull WebSocketSession session, BinaryMessage message) throws Exception { - sendError(session, "Binary audio relay over WebSocket is not supported. Use WebRTC transport for live audio."); + protected void handleBinaryMessage(@NonNull WebSocketSession session, BinaryMessage message) + throws Exception { + sendError( + session, + "Binary audio relay over WebSocket is not supported. Use WebRTC transport for live audio."); } // 정상 종료된 세션을 회의방에서 제거하고 퇴장 이벤트를 전파합니다. @Override - public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) throws Exception { + public void afterConnectionClosed( + @NonNull WebSocketSession session, @NonNull CloseStatus status) throws Exception { removeParticipant(session); } // 전송 오류가 발생한 세션을 정리하고 필요하면 서버 에러 상태로 연결을 닫습니다. @Override - public void handleTransportError(@NonNull WebSocketSession session, @NonNull Throwable exception) throws Exception { + public void handleTransportError( + @NonNull WebSocketSession session, @NonNull Throwable exception) throws Exception { removeParticipant(session); if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR); @@ -112,7 +132,9 @@ public void handleTransportError(@NonNull WebSocketSession session, @NonNull Thr // 세션을 회의방에서 제거하고 퇴장 이벤트 및 갱신된 roster를 방송합니다. private void removeParticipant(WebSocketSession session) { - Long meetingId = getAttribute(session, MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, Long.class); + Long meetingId = + getAttribute( + session, MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, Long.class); if (meetingId == null) { return; } @@ -123,13 +145,16 @@ private void removeParticipant(WebSocketSession session) { } List currentParticipants = participantSummaries(meetingId); - meetingSocketRoomService.broadcastText(meetingId, new TextMessage(JsonConverter.toJson(new ParticipantLeftMessage( - MeetingMessageType.PARTICIPANT_LEFT, + meetingSocketRoomService.broadcastText( meetingId, - removed.memberId(), - removed.name(), - now() - )))); + new TextMessage( + JsonConverter.toJson( + new ParticipantLeftMessage( + MeetingMessageType.PARTICIPANT_LEFT, + meetingId, + removed.memberId(), + removed.name(), + now())))); broadcastRoster(meetingId, currentParticipants); if (currentParticipants.isEmpty()) { @@ -141,8 +166,9 @@ private void removeParticipant(WebSocketSession session) { private void broadcastRoster(Long meetingId, List participantSummaries) { meetingSocketRoomService.broadcastText( meetingId, - new TextMessage(JsonConverter.toJson(RosterMessage.create(meetingId, participantSummaries))) - ); + new TextMessage( + JsonConverter.toJson( + RosterMessage.create(meetingId, participantSummaries)))); } // 핸드셰이크에서 저장한 속성으로 참가자 정보를 복원합니다. @@ -150,10 +176,16 @@ private MeetingParticipant createParticipant(WebSocketSession session) { return createParticipant(session, session); } - private MeetingParticipant createParticipant(WebSocketSession session, WebSocketSession outboundSession) { - Long meetingId = getAttribute(session, MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, Long.class); - Long memberId = getAttribute(session, MeetingSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE, Long.class); - String name = getAttribute(session, MeetingSocketAuthInterceptor.MEMBER_NAME_ATTRIBUTE, String.class); + private MeetingParticipant createParticipant( + WebSocketSession session, WebSocketSession outboundSession) { + Long meetingId = + getAttribute( + session, MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, Long.class); + Long memberId = + getAttribute(session, MeetingSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE, Long.class); + String name = + getAttribute( + session, MeetingSocketAuthInterceptor.MEMBER_NAME_ATTRIBUTE, String.class); if (meetingId == null || memberId == null || !StringUtils.hasText(name)) { throw new IllegalStateException("WebSocket participant attributes are missing"); @@ -169,12 +201,8 @@ private void sendError(WebSocketSession session, String message) { private void sendError(WebSocketSession session, MeetingMessageType type, String message) { try { - session.sendMessage(new TextMessage( - JsonConverter.toJson( - new ErrorMessage( - type, message - ) - ))); + session.sendMessage( + new TextMessage(JsonConverter.toJson(new ErrorMessage(type, message)))); } catch (Exception exception) { throw new IllegalStateException("Failed to send websocket error message", exception); } @@ -193,20 +221,22 @@ private List participantSummaries(Long meetingId) { return meetingSocketRoomService.listParticipants(meetingId); } - private void broadcastTextMessage(MeetingParticipant participant, MeetingMessageType type, MeetingSocketMessage incoming) { + private void broadcastTextMessage( + MeetingParticipant participant, + MeetingMessageType type, + MeetingSocketMessage incoming) { logIncomingText(participant, type, incoming); meetingSocketRoomService.broadcastText( participant.meetingId(), - JsonConverter.toJson(MeetingTextMessage.createTextMessage( - participant, - type, - null, - Optional.ofNullable(incoming.text()).orElse(""), - incoming.payload() - )) - ); - if (type == MeetingMessageType.SPEECH - && StringUtils.hasText(incoming.text())) { + JsonConverter.toJson( + MeetingTextMessage.createTextMessage( + participant, + type, + null, + Optional.ofNullable(incoming.text()).orElse(""), + incoming.payload()))); + if (type == MeetingMessageType.SPEECH && StringUtils.hasText(incoming.text())) { + LocalDateTime speechDateTime = LocalDateTime.now(); meetingLiveMessageRepository.append( participant.meetingId(), new LiveMessageEntry( @@ -216,9 +246,23 @@ private void broadcastTextMessage(MeetingParticipant participant, MeetingMessage incoming.targetMemberId(), incoming.text(), incoming.payload(), - LocalDateTime.now() - ) - ); + speechDateTime)); + persistSpeech(participant, incoming.text(), speechDateTime); + } + } + + // 발화를 회의록으로 남깁니다. 저장이 실패해도 진행 중인 회의를 끊지 않습니다. + private void persistSpeech( + MeetingParticipant participant, String text, LocalDateTime speechDateTime) { + try { + meetingDialogueCommandService.appendSpeech( + participant.meetingId(), participant.memberId(), text, speechDateTime); + } catch (Exception exception) { + log.error( + "발화 저장 실패: meetingId={}, memberId={}", + participant.meetingId(), + participant.memberId(), + exception); } } @@ -226,8 +270,7 @@ private void forwardSignal( WebSocketSession session, MeetingParticipant participant, MeetingMessageType type, - MeetingSocketMessage incoming - ) { + MeetingSocketMessage incoming) { if (incoming.targetMemberId() == null) { sendError(session, "targetMemberId is required for " + type.value()); return; @@ -236,14 +279,13 @@ private void forwardSignal( meetingSocketRoomService.sendToMember( participant.meetingId(), incoming.targetMemberId(), - JsonConverter.toJson(MeetingTextMessage.createTextMessage( - participant, - type, - incoming.targetMemberId(), - null, - incoming.payload() - )) - ); + JsonConverter.toJson( + MeetingTextMessage.createTextMessage( + participant, + type, + incoming.targetMemberId(), + null, + incoming.payload()))); } // 웹소켓 메시지에 사용할 현재 시각 문자열을 생성합니다. @@ -251,7 +293,10 @@ private String now() { return Instant.now().toString(); } - private String logIncomingText(MeetingParticipant participant, MeetingMessageType type, MeetingSocketMessage incoming) { + private String logIncomingText( + MeetingParticipant participant, + MeetingMessageType type, + MeetingSocketMessage incoming) { String text = Optional.ofNullable(incoming.text()).orElse(""); log.info( "meeting text received: meetingId={}, memberId={}, name={}, type={}, targetMemberId={}, text={}", @@ -260,17 +305,12 @@ private String logIncomingText(MeetingParticipant participant, MeetingMessageTyp participant.name(), type.value(), incoming.targetMemberId(), - text - ); + text); return text; } private WebSocketSession decorate(WebSocketSession session) { return new ConcurrentWebSocketSessionDecorator( - session, - SESSION_SEND_TIME_LIMIT_MS, - SESSION_BUFFER_SIZE_LIMIT_BYTES - ); + session, SESSION_SEND_TIME_LIMIT_MS, SESSION_BUFFER_SIZE_LIMIT_BYTES); } - } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java b/server/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java index 9c2a08a..b3ffcfe 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java @@ -1,18 +1,40 @@ package com.whylog.server.domain.meeting.socket.util; +import java.time.Duration; +import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; // 웹소켓 연산 담당 클래스 public class WebSocketTimeUtil { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final String ZERO_ELAPSED = "00:00:00"; // 웹소켓 메시지에 사용할 현재 시각 문자열을 생성합니다. public static String now() { - return ZonedDateTime.now(ZoneId.systemDefault()) - .format(FORMATTER); + return ZonedDateTime.now(ZoneId.systemDefault()).format(FORMATTER); } + // 회의 시작 시각 대비 경과 시간을 FastAPI가 쓰는 HH:MM:SS 형식으로 만듭니다. + public static String formatElapsed(LocalDateTime startDateTime, LocalDateTime target) { + try { + if (startDateTime == null || target == null) { + return ZERO_ELAPSED; + } + + long elapsedSeconds = Duration.between(startDateTime, target).getSeconds(); + if (elapsedSeconds < 0) { + return ZERO_ELAPSED; + } + + long hours = elapsedSeconds / 3600; + long minutes = (elapsedSeconds % 3600) / 60; + long seconds = elapsedSeconds % 60; + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } catch (RuntimeException exception) { + return ZERO_ELAPSED; + } + } } diff --git a/server/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java b/server/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java index 258bc89..c9b471a 100644 --- a/server/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java +++ b/server/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; -import java.time.Duration; +import com.whylog.server.domain.meeting.socket.util.WebSocketTimeUtil; import java.time.LocalDateTime; // FastAPI로 전달할 실시간 발화 항목입니다. @@ -16,15 +16,15 @@ public record LiveMessagePayload( String timestamp, Long targetMemberId, String text, - JsonNode payload -) { + JsonNode payload) { @JsonProperty("type") public String type() { return "TEXT"; } - public static LiveMessagePayload from(LiveMessageEntry entry, LocalDateTime meetingStartDateTime) { + public static LiveMessagePayload from( + LiveMessageEntry entry, LocalDateTime meetingStartDateTime) { return new LiveMessagePayload( entry.meetingId(), entry.fromMemberId(), @@ -32,27 +32,10 @@ public static LiveMessagePayload from(LiveMessageEntry entry, LocalDateTime meet formatElapsed(meetingStartDateTime, entry.receivedAt()), entry.targetMemberId(), entry.text(), - entry.payload() - ); + entry.payload()); } private static String formatElapsed(LocalDateTime startDateTime, LocalDateTime receivedAt) { - try { - if (startDateTime == null || receivedAt == null) { - return "00:00:00"; - } - - long elapsedSeconds = Duration.between(startDateTime, receivedAt).getSeconds(); - if (elapsedSeconds < 0) { - return "00:00:00"; - } - - long hours = elapsedSeconds / 3600; - long minutes = (elapsedSeconds % 3600) / 60; - long seconds = elapsedSeconds % 60; - return String.format("%02d:%02d:%02d", hours, minutes, seconds); - } catch (RuntimeException exception) { - return "00:00:00"; - } + return WebSocketTimeUtil.formatElapsed(startDateTime, receivedAt); } } diff --git a/server/src/main/java/com/whylog/server/global/external/fast/dto/request/TranscriptSegmentPayload.java b/server/src/main/java/com/whylog/server/global/external/fast/dto/request/TranscriptSegmentPayload.java new file mode 100644 index 0000000..a2fd2d0 --- /dev/null +++ b/server/src/main/java/com/whylog/server/global/external/fast/dto/request/TranscriptSegmentPayload.java @@ -0,0 +1,20 @@ +package com.whylog.server.global.external.fast.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/** + * FastAPI 회의 분석 재추출에 넘길 전사 세그먼트입니다. + * + *

speaker와 isFinal은 FastAPI에서 필수 필드라 비우면 422가 납니다. 키 이름이 어긋나도 같은 결과라, 전역 Jackson 설정에 기대지 않고 + * snake_case를 명시합니다. + */ +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record TranscriptSegmentPayload( + Long messageId, + String speaker, + Long memberId, + String startTime, + String endTime, + String text, + Boolean isFinal) {} diff --git a/server/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java b/server/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java deleted file mode 100644 index 32c675f..0000000 --- a/server/src/main/java/com/whylog/server/global/external/livekit/LiveKitEgressClient.java +++ /dev/null @@ -1,219 +0,0 @@ -package com.whylog.server.global.external.livekit; - -import com.whylog.server.global.util.json.JsonConverter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.HttpClientErrorException; - -import java.net.URI; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -@Slf4j -@Component -@RequiredArgsConstructor -public class LiveKitEgressClient { - - @Value("${livekit.url}") - private String liveKitUrl; - - @Value("${aws.s3.bucket}") - private String bucket; - - @Value("${aws.s3.region}") - private String region; - - @Value("${aws.s3.access-key}") - private String accessKey; - - @Value("${aws.s3.secret-key}") - private String secretKey; - - @Value("${aws.s3.endpoint:}") - private String s3Endpoint; - - @Value("${aws.s3.force-path-style:false}") - private boolean forcePathStyle; - - private final RestClient restClient = RestClient.create(); - - public String startRoomAudioEgress(String egressToken, String roomName, String recordingKey) { - Map request = new LinkedHashMap<>(); - request.put("room_name", roomName); - request.put("audio_only", true); - request.put("file_outputs", List.of(buildFileOutput(recordingKey))); - - Map response = post("/twirp/livekit.Egress/StartRoomCompositeEgress", egressToken, request); - Object egressId = response.get("egress_id"); - if (egressId == null) { - throw new IllegalStateException("LiveKit egress response did not include egress_id"); - } - return egressId.toString(); - } - - public void createRoom(String roomCreateToken, String roomName) { - Map request = new LinkedHashMap<>(); - request.put("name", roomName); - - try { - post("/twirp/livekit.RoomService/CreateRoom", roomCreateToken, request); - } catch (HttpClientErrorException.Conflict e) { - log.info("LiveKit room already exists: roomName={}", roomName); - } - } - - public void stopEgress(String egressToken, String egressId) { - Map request = Map.of("egress_id", egressId); - try { - post("/twirp/livekit.Egress/StopEgress", egressToken, request); - } catch (HttpClientErrorException e) { - if (isAlreadyCompletedEgress(e)) { - log.info("LiveKit egress already completed: egressId={}", egressId); - return; - } - - throw e; - } - } - - private boolean isAlreadyCompletedEgress(HttpClientErrorException e) { - String responseBody = e.getResponseBodyAsString(); - return e.getStatusCode().value() == HttpStatus.PRECONDITION_FAILED.value() - && responseBody.contains("EGRESS_COMPLETE") - && responseBody.contains("cannot be stopped"); - } - - public void removeParticipant(String roomAdminToken, String roomName, String identity) { - Map request = new LinkedHashMap<>(); - request.put("room", roomName); - request.put("identity", identity); - post("/twirp/livekit.RoomService/RemoveParticipant", roomAdminToken, request); - } - - @SuppressWarnings("unchecked") - public List> listParticipants(String roomAdminToken, String roomName) { - Map response = post("/twirp/livekit.RoomService/ListParticipants", roomAdminToken, Map.of("room", roomName)); - Object participants = response.get("participants"); - if (participants instanceof List participantList) { - return participantList.stream() - .filter(Map.class::isInstance) - .map(item -> (Map) item) - .toList(); - } - - return List.of(); - } - - public void deleteRoom(String roomCreateToken, String roomName) { - Map request = new LinkedHashMap<>(); - request.put("room", roomName); - post("/twirp/livekit.RoomService/DeleteRoom", roomCreateToken, request); - } - - @SuppressWarnings("unchecked") - public List> listRooms(String roomListToken) { - Map response = post("/twirp/livekit.RoomService/ListRooms", roomListToken, Map.of()); - Object rooms = response.get("rooms"); - if (rooms instanceof List roomList) { - return roomList.stream() - .filter(Map.class::isInstance) - .map(item -> (Map) item) - .toList(); - } - - return List.of(); - } - - @SuppressWarnings("unchecked") - private Map post(String path, String egressToken, Map request) { - String apiBaseUrl = toHttpsBaseUrl(liveKitUrl); - URI uri = URI.create(apiBaseUrl + path); - - Object body = request; - String requestJson = JsonConverter.toJson(redactForLogging(request)); - log.info("LiveKit egress request: {}", requestJson); - - Map response = restClient.post() - .uri(uri) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + egressToken) - .body(body) - .retrieve() - .body(Map.class); - - if (response == null) { - throw new IllegalStateException("LiveKit egress response is empty"); - } - - log.info("LiveKit egress response: {}", JsonConverter.toJson(response)); - return response; - } - - private Map redactForLogging(Map request) { - Map sanitized = new LinkedHashMap<>(); - request.forEach((key, value) -> sanitized.put(key, redactValue(key, value))); - return sanitized; - } - - private Object redactValue(String key, Object value) { - if ("access_key".equals(key) || "secret".equals(key)) { - return "***"; - } - - if (value instanceof Map map) { - Map sanitized = new LinkedHashMap<>(); - map.forEach((nestedKey, nestedValue) -> - sanitized.put(String.valueOf(nestedKey), redactValue(String.valueOf(nestedKey), nestedValue)) - ); - return sanitized; - } - - if (value instanceof List list) { - return list.stream() - .map(item -> item instanceof Map mapItem - ? redactForLogging((Map) mapItem) - : item) - .toList(); - } - - return value; - } - - private Map buildFileOutput(String recordingKey) { - Map s3 = new LinkedHashMap<>(); - s3.put("access_key", accessKey); - s3.put("secret", secretKey); - s3.put("bucket", bucket); - s3.put("region", region); - if (StringUtils.hasText(s3Endpoint)) { - s3.put("endpoint", s3Endpoint); - } - s3.put("force_path_style", forcePathStyle); - - Map fileOutput = new LinkedHashMap<>(); - fileOutput.put("filepath", recordingKey); - fileOutput.put("s3", s3); - return fileOutput; - } - - private String toHttpsBaseUrl(String wsUrl) { - if (wsUrl.startsWith("wss://")) { - return "https://" + wsUrl.substring("wss://".length()); - } - if (wsUrl.startsWith("ws://")) { - return "http://" + wsUrl.substring("ws://".length()); - } - if (wsUrl.startsWith("https://") || wsUrl.startsWith("http://")) { - return wsUrl; - } - return "https://" + wsUrl; - } - -} diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml index ae82619..ea17b76 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -52,11 +52,6 @@ jwt: access-token-expire-time: ${JWT_ACCESS_TOKEN_EXPIRE_TIME:3600000} refresh-token-expire-time: ${JWT_REFRESH_TOKEN_EXPIRE_TIME:1209600000} -livekit: - url: ${LIVEKIT_URL:wss://example.livekit.invalid} - api-key: ${LIVEKIT_API_KEY:devkey} - api-secret: ${LIVEKIT_API_SECRET:01234567890123456789012345678901} - github: token: encryption: diff --git a/server/src/main/resources/db/migration/V2__drop_meeting_audio_egress_id.sql b/server/src/main/resources/db/migration/V2__drop_meeting_audio_egress_id.sql new file mode 100644 index 0000000..dd4e442 --- /dev/null +++ b/server/src/main/resources/db/migration/V2__drop_meeting_audio_egress_id.sql @@ -0,0 +1,3 @@ +-- 실시간 회의를 WebRTC로 직접 구현하면서 LiveKit Egress 녹음을 제거했습니다. +-- egress id를 채우는 주체가 사라져 컬럼을 내립니다. 오디오 다시듣기용 audio_key는 유지합니다. +alter table meeting drop column audio_egress_id; diff --git a/server/src/main/resources/db/mock/mock.sql b/server/src/main/resources/db/mock/mock.sql index 5f41dd1..19b3aeb 100644 --- a/server/src/main/resources/db/mock/mock.sql +++ b/server/src/main/resources/db/mock/mock.sql @@ -84,9 +84,9 @@ INSERT INTO commit_analysis (commit_Analysis_id, created_at, updated_at, commit_ (5, NOW(), NOW(), 9, 'Flyway 마이그레이션과 mock 데이터 스크립트를 도입했다.', 0); -- meeting (종료된 회의 2건) -INSERT INTO meeting (meeting_id, created_at, updated_at, team_id, name, start_date_time, end_date_time, audio_key, audio_egress_id, is_normally_ended) VALUES - (1, NOW(), NOW(), 1, '주간 스프린트 회의', DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY) + INTERVAL 1 HOUR, NULL, NULL, 1), - (2, NOW(), NOW(), 1, '결정 근거 회고 회의', DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY) + INTERVAL 45 MINUTE, NULL, NULL, 1); +INSERT INTO meeting (meeting_id, created_at, updated_at, team_id, name, start_date_time, end_date_time, audio_key, is_normally_ended) VALUES + (1, NOW(), NOW(), 1, '주간 스프린트 회의', DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY) + INTERVAL 1 HOUR, NULL, 1), + (2, NOW(), NOW(), 1, '결정 근거 회고 회의', DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY) + INTERVAL 45 MINUTE, NULL, 1); INSERT INTO meeting_member (meeting_id, member_id, created_at, updated_at, role) VALUES (1, 1, NOW(), NOW(), 'OWNER'), From 2b812d4d14490edd50a852f970fbbee43d35f1fd Mon Sep 17 00:00:00 2001 From: hyeseong Date: Wed, 19 Aug 2026 11:29:30 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(web):=20=EB=85=B9=EC=9D=8C=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=9A=8C=EC=9D=98=EC=97=90=EC=84=9C=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버에서 LiveKit Egress를 제거해 새 회의는 오디오가 생기지 않는다. 플레이어를 무조건 렌더하면 완료된 회의마다 동작하지 않는 바가 남는다. 오디오가 있는 과거 회의에서만 보이게 한다. Co-Authored-By: Claude Opus 5 --- web/src/pages/meeting/CompletedPage.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/src/pages/meeting/CompletedPage.tsx b/web/src/pages/meeting/CompletedPage.tsx index 3618266..8cf752e 100644 --- a/web/src/pages/meeting/CompletedPage.tsx +++ b/web/src/pages/meeting/CompletedPage.tsx @@ -150,12 +150,15 @@ const CompletedPage = () => { />

-
- -
+ {/* 실시간 회의가 WebRTC로 바뀌며 녹음이 사라졌다. 오디오가 있는 과거 회의에서만 보인다. */} + {audio?.audio_url && ( +
+ +
+ )} From 916eb9d6063c3fcfa9d75ab5b91bed1ee6b0890a Mon Sep 17 00:00:00 2001 From: whylog-dev Date: Fri, 21 Aug 2026 12:52:33 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs(docs):=20PR-16=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=20=EA=B7=BC=EA=B1=B0=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/pr-reviews/PR-16.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/pr-reviews/PR-16.md diff --git a/docs/pr-reviews/PR-16.md b/docs/pr-reviews/PR-16.md new file mode 100644 index 0000000..bf70d1f --- /dev/null +++ b/docs/pr-reviews/PR-16.md @@ -0,0 +1,37 @@ +# PR-16 AI 리뷰 기록 + +- PR: https://github.com/WhyLog-App/WhyLog/pull/16 +- 제목: Feat/meeting webrtc +- 브랜치: `develop` ← `feat/meeting-webrtc` +- HEAD: `2b812d4d14490edd50a852f970fbbee43d35f1fd` +- 입력 digest: `bc7390324dda2662c9992a58205ebdc9331d4a4a248253b1cd2f0ddef0b33971` +- 모델: Google `gemini-3.6-flash` +- 상태: **BLOCKED** +- 생성 시각(UTC): 2026-08-21T03:52:28+00:00 + +## 요약 + +LiveKit SFU 기반 실시간 회의를 P2P WebRTC mesh 및 자막 기반 분석 파이프라인으로 전환하는 변경입니다. 실시간 발화 저장 서비스에서 회원 조회를 위한 인자가 잘못 전달되는 버그가 있어 수정이 필요합니다. + +## 이번 실행에서 새로 발견됨 + +|상태|구분|위치|제목|이유|근거|권장 처리|반영 HEAD| +|---|---|---|---|---|---|---|---| +|new|차단|server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java:31|발화 저장 시 memberId 대신 meetingId 전달 오류|Member 엔티티 조회 시 memberId가 아닌 meetingId를 인자로 전달하여 잘못된 회원 엔티티가 매핑되거나 DB 식별자 불일치 예외가 발생합니다.|데이터 무결성 및 비즈니스 로직 정확성|memberRepository.getReferenceById(memberId); 로 수정합니다.|| + +## 이전 실행부터 계속 남아있음 + +없음 + +## 현재까지 사라짐(자동 추정) + +없음 + +## 실행 이력 + +|HEAD|상태|모델|전체|신규|계속|해결|시각| +|---|---|---|---:|---:|---:|---:|---| +|2b812d4d1449|BLOCKED|Google gemini-3.6-flash|1|1|||2026-08-21T03:52:28+00:00| + + + From 846fc3a140974c5fc5f10fe73bf6dd4602a0567e Mon Sep 17 00:00:00 2001 From: hyeseong Date: Fri, 21 Aug 2026 18:26:26 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor(server):=20=EB=B0=9C=ED=99=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=9D=B8=EC=9E=90=EB=A5=BC=20LiveMessageE?= =?UTF-8?q?ntry=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendSpeech가 meetingId와 memberId를 Long 두 개로 나란히 받고 있었다. 호출부에서 뒤바꿔 넣어도 컴파일이 통과하고 아무도 잡지 못한다. 실제로 AI 리뷰가 이 시그니처를 인자 순서 오류로 오독했다. 핸들러가 메모리 버퍼용으로 이미 만들던 LiveMessageEntry를 그대로 넘긴다. 새 타입을 만들지 않았고(MeetingLiveMessageBundleService가 같은 타입을 쓴다), 메모리와 회의록이 같은 객체를 공유해 타임스탬프도 어긋나지 않는다. 동작 변경 없음. Co-Authored-By: Claude Opus 5 --- .../MeetingDialogueCommandService.java | 17 ++++++++-------- .../meeting/socket/MeetingSocketHandler.java | 20 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java index 6e368b7..3034d1e 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java @@ -4,9 +4,9 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.repository.DialogueRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.repository.MemberRepository; -import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,13 +22,14 @@ public class MeetingDialogueCommandService { private final MemberRepository memberRepository; /** - * 발화 한 건을 저장합니다. 발화마다 호출되므로 연관 엔티티는 프록시로만 잡아 INSERT 한 번으로 끝냅니다. meetingId·memberId는 웹소켓 핸드셰이크에서 - * 검증된 값이라 존재 여부를 다시 조회하지 않습니다. + * 발화 한 건을 저장합니다. 발화마다 호출되므로 연관 엔티티는 프록시로만 잡아 INSERT 한 번으로 끝냅니다. 회의·회원 식별자는 웹소켓 핸드셰이크에서 검증된 값이라 + * 존재 여부를 다시 조회하지 않습니다. + * + *

같은 타입 식별자를 나열해 받으면 호출부에서 뒤바꿔도 아무도 잡지 못하므로, 발화 한 건을 통째로 받습니다. */ - public void appendSpeech( - Long meetingId, Long memberId, String text, LocalDateTime speechDateTime) { - Meeting meeting = meetingRepository.getReferenceById(meetingId); - Member member = memberRepository.getReferenceById(memberId); - dialogueRepository.save(Dialogue.create(meeting, member, text, speechDateTime)); + public void appendSpeech(LiveMessageEntry entry) { + Meeting meeting = meetingRepository.getReferenceById(entry.meetingId()); + Member member = memberRepository.getReferenceById(entry.fromMemberId()); + dialogueRepository.save(Dialogue.create(meeting, member, entry.text(), entry.receivedAt())); } } diff --git a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java index cf7c51f..5dacb6b 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java @@ -236,9 +236,8 @@ private void broadcastTextMessage( Optional.ofNullable(incoming.text()).orElse(""), incoming.payload()))); if (type == MeetingMessageType.SPEECH && StringUtils.hasText(incoming.text())) { - LocalDateTime speechDateTime = LocalDateTime.now(); - meetingLiveMessageRepository.append( - participant.meetingId(), + // 메모리 버퍼와 회의록이 같은 발화 객체를 공유해 타임스탬프가 어긋나지 않게 한다. + LiveMessageEntry entry = new LiveMessageEntry( participant.meetingId(), participant.memberId(), @@ -246,22 +245,21 @@ private void broadcastTextMessage( incoming.targetMemberId(), incoming.text(), incoming.payload(), - speechDateTime)); - persistSpeech(participant, incoming.text(), speechDateTime); + LocalDateTime.now()); + meetingLiveMessageRepository.append(participant.meetingId(), entry); + persistSpeech(entry); } } // 발화를 회의록으로 남깁니다. 저장이 실패해도 진행 중인 회의를 끊지 않습니다. - private void persistSpeech( - MeetingParticipant participant, String text, LocalDateTime speechDateTime) { + private void persistSpeech(LiveMessageEntry entry) { try { - meetingDialogueCommandService.appendSpeech( - participant.meetingId(), participant.memberId(), text, speechDateTime); + meetingDialogueCommandService.appendSpeech(entry); } catch (Exception exception) { log.error( "발화 저장 실패: meetingId={}, memberId={}", - participant.meetingId(), - participant.memberId(), + entry.meetingId(), + entry.fromMemberId(), exception); } } From d9a739a1d05e22cf1998cc695adc2ed9861f9172 Mon Sep 17 00:00:00 2001 From: whylog-dev Date: Fri, 21 Aug 2026 18:28:43 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs(docs):=20PR-16=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=20=EA=B7=BC=EA=B1=B0=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/pr-reviews/PR-16.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/pr-reviews/PR-16.md b/docs/pr-reviews/PR-16.md index bf70d1f..0120a35 100644 --- a/docs/pr-reviews/PR-16.md +++ b/docs/pr-reviews/PR-16.md @@ -1,23 +1,21 @@ # PR-16 AI 리뷰 기록 - PR: https://github.com/WhyLog-App/WhyLog/pull/16 -- 제목: Feat/meeting webrtc +- 제목: feat(web, server): 실시간 회의 구현 - 브랜치: `develop` ← `feat/meeting-webrtc` -- HEAD: `2b812d4d14490edd50a852f970fbbee43d35f1fd` -- 입력 digest: `bc7390324dda2662c9992a58205ebdc9331d4a4a248253b1cd2f0ddef0b33971` +- HEAD: `846fc3a140974c5fc5f10fe73bf6dd4602a0567e` +- 입력 digest: `2e1055dcd0106a5d7b01942700289c0319fea05bd614e1d08a3a6b93e6a2dcfe` - 모델: Google `gemini-3.6-flash` -- 상태: **BLOCKED** -- 생성 시각(UTC): 2026-08-21T03:52:28+00:00 +- 상태: **PASS** +- 생성 시각(UTC): 2026-08-21T09:28:37+00:00 ## 요약 -LiveKit SFU 기반 실시간 회의를 P2P WebRTC mesh 및 자막 기반 분석 파이프라인으로 전환하는 변경입니다. 실시간 발화 저장 서비스에서 회원 조회를 위한 인자가 잘못 전달되는 버그가 있어 수정이 필요합니다. +실시간 회의 방식을 LiveKit SFU에서 P2P WebRTC mesh로 전환하고, 오디오 녹음 없이 실시간 자막(Dialogue)으로 회의를 분석하는 파이프라인을 구현했습니다. 관련 LiveKit 코드 정리 및 DB 마이그레이션(V2), mock 데이터 수정이 충실히 포함되었으며 차단 항목은 없습니다. ## 이번 실행에서 새로 발견됨 -|상태|구분|위치|제목|이유|근거|권장 처리|반영 HEAD| -|---|---|---|---|---|---|---|---| -|new|차단|server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java:31|발화 저장 시 memberId 대신 meetingId 전달 오류|Member 엔티티 조회 시 memberId가 아닌 meetingId를 인자로 전달하여 잘못된 회원 엔티티가 매핑되거나 DB 식별자 불일치 예외가 발생합니다.|데이터 무결성 및 비즈니스 로직 정확성|memberRepository.getReferenceById(memberId); 로 수정합니다.|| +없음 ## 이전 실행부터 계속 남아있음 @@ -25,13 +23,16 @@ LiveKit SFU 기반 실시간 회의를 P2P WebRTC mesh 및 자막 기반 분석 ## 현재까지 사라짐(자동 추정) -없음 +|상태|구분|위치|제목|이유|근거|권장 처리|반영 HEAD| +|---|---|---|---|---|---|---|---| +|resolved|차단|server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java:31|발화 저장 시 memberId 대신 meetingId 전달 오류|Member 엔티티 조회 시 memberId가 아닌 meetingId를 인자로 전달하여 잘못된 회원 엔티티가 매핑되거나 DB 식별자 불일치 예외가 발생합니다.|데이터 무결성 및 비즈니스 로직 정확성|memberRepository.getReferenceById(memberId); 로 수정합니다.|846fc3a140974c5fc5f10fe73bf6dd4602a0567e| ## 실행 이력 |HEAD|상태|모델|전체|신규|계속|해결|시각| |---|---|---|---:|---:|---:|---:|---| +|846fc3a14097|PASS|Google gemini-3.6-flash||||1|2026-08-21T09:28:37+00:00| |2b812d4d1449|BLOCKED|Google gemini-3.6-flash|1|1|||2026-08-21T03:52:28+00:00| - - + +