diff --git a/README.md b/README.md index e55d7e49..bea0475a 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,15 @@ > 1️⃣ 캠퍼들은 코어타임 시간에 실시간 방송을 키면서 부스트 캠프 활동에 참여할 수 있습니다.
> 2️⃣ 화면공유 on/off, 캠 on/off, 마이크 on/off 기능으로 캠퍼들이 보다 자유로운 방송을 할 수 있도록 돕습니다.
> 3️⃣ 별도의 송출 소프트웨어 없이 서비스 내에서 방송 송출과 화면 배치 과정이 자동으로 이루어져 캠퍼들이 부담없이 방송할 수 있는 환경을 제공합니다. +> +![화면 송출 데모](https://github.com/user-attachments/assets/aaf18b1f-9192-4c3e-8059-0d9c0603184d) ### 👀 실시간 방송 시청 > 1️⃣ 캠퍼들은 서로의 방송을 시청하면서 실시간으로 서로의 학습 경험을 공유할 수 있습니다.
> 2️⃣ 시청화면 하단에 방송중인 캠퍼의 정보를 제공하여 온라인 네트워킹 환경을 제공합니다.
+![방송 시청 데모](https://github.com/user-attachments/assets/c63cd77a-cc14-49e4-b3ed-36bc6ec26582) + ### 💬 채팅 > 1️⃣ 캠퍼들은 채팅을 통해 실시간으로 소통할 수 있습니다.
> 2️⃣ 방송 송출창과 시청창 모두 채팅 기능을 제공하여 방송중인 캠퍼와 시청하는 캠퍼 모두 자유롭게 지식을 공유하고 유대감을 쌓을 수 있습니다.
@@ -56,14 +60,20 @@ > 1️⃣ 실시간 녹화 기능을 제공하여 코어타임 학습 중 기억하고 싶은 순간을 기록할 수 있습니다.
> 2️⃣ 방송 중 기록한 녹화본들은 출석 내역에서 확인하며 스스로의 학습 경험을 돌아볼 수 있습니다.
+![녹화 데모](https://github.com/user-attachments/assets/905fa5b5-3531-4dbc-b92d-1dcf94d5fcc9) + ### ✏️ 출석 > 1️⃣ 캠퍼는 마이페이지에서 본인의 출석 내역을 한 눈에 확인할 수 있습니다.
> 2️⃣ 코어타임 시간 내에 송출되는 방송 시간을 기반으로 자동으로 캠퍼들의 출석이 관리됩니다.
+![출석 데모](https://github.com/user-attachments/assets/63adc867-a159-4bb4-a7f2-fea5edd892ab) + ### 📚 아카이브 > 1️⃣ 캠퍼들은 메인페이지에서 여러개로 나누어진 베이스캠프를 한 번에 모아서 관리할 수 있습니다.
> 2️⃣ 자유롭게 하이퍼링크를 등록하여 맞춤형 온라인 베이스 캠프를 구성할 수 있습니다.
+![아카이브 데모](https://github.com/user-attachments/assets/dafcd2ca-df14-4720-8f54-0ae4eb90be50) + # 핵심 개발 일지 | **핵심 기능** | **설명** | |:---|:---| diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 222dd05e..0e07bedf 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -24,11 +24,11 @@ export class AuthController { @Get('/github/callback') @UseGuards(GithubAuthGuard) - signinGithubCallback(@UserReq() member: Member, @Res() res: Response) { - const accessToken = this.authService.login(member); + async signinGithubCallback(@UserReq() member: Member, @Res() res: Response) { + const { accessToken, isNecessaryInfo } = this.authService.login(member); const CALLBACK_URI = this.configService.get('CALLBACK_URI'); - res.redirect(`${CALLBACK_URI}/auth?accessToken=${accessToken}`); + res.redirect(`${CALLBACK_URI}/auth?accessToken=${accessToken}&isNecessaryInfo=${isNecessaryInfo}`); } @Get('/signin/google') @@ -39,10 +39,10 @@ export class AuthController { @Get('/google/callback') @UseGuards(GoogleAuthGuard) - signinGoogleCallback(@UserReq() member: Member, @Res() res: Response) { - const accessToken = this.authService.login(member); + async signinGoogleCallback(@UserReq() member: Member, @Res() res: Response) { + const { accessToken, isNecessaryInfo } = this.authService.login(member); const CALLBACK_URI = this.configService.get('CALLBACK_URI'); - res.redirect(`${CALLBACK_URI}/auth?accessToken=${accessToken}`); + res.redirect(`${CALLBACK_URI}/auth?accessToken=${accessToken}&isNecessaryInfo=${isNecessaryInfo}`); } } diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 3ddb9bc7..bf550ad1 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -23,8 +23,9 @@ export class AuthService { login(member: Member) { const payload = { id: member.id, camperId: member.camperId }; const accessToken = this.jwtService.sign(payload); + const isNecessaryInfo = Boolean(member.field && member.name && member.camperId); - return accessToken; + return { accessToken, isNecessaryInfo }; } async validateMember(id: number) { diff --git a/apps/api/src/member/dto/update-member-info.dto.ts b/apps/api/src/member/dto/update-member-info.dto.ts index e4276415..5e750f93 100644 --- a/apps/api/src/member/dto/update-member-info.dto.ts +++ b/apps/api/src/member/dto/update-member-info.dto.ts @@ -19,7 +19,7 @@ export class UpdateMemberInfoDto { @ApiProperty({ example: 'J000' }) camperId: string; @ApiProperty({ example: 'WEB' }) - type: FieldEnum; + field: FieldEnum; @ApiProperty({ type: Contacts }) contacts: Contacts; @@ -27,7 +27,7 @@ export class UpdateMemberInfoDto { const member = new Member(); member.name = this.name; member.camperId = this.camperId; - member.field = this.type; + member.field = this.field; member.email = this.contacts.email; member.github = this.contacts.github; member.blog = this.contacts.blog; diff --git a/apps/chat/src/chat/chat.gateway.ts b/apps/chat/src/chat/chat.gateway.ts index 53c4bc2a..092d6030 100644 --- a/apps/chat/src/chat/chat.gateway.ts +++ b/apps/chat/src/chat/chat.gateway.ts @@ -32,9 +32,10 @@ export class ChatGateway implements OnGatewayDisconnect { }; } //룸입장 + @UseGuards(JWTAuthGuard) @SubscribeMessage('joinRoom') async handleJoinRoom(@MessageBody('roomId') roomId: string, @ConnectedSocket() client: Socket) { - this.chatService.joinRoom(roomId, client); + await this.chatService.joinRoom(roomId, client); } //룸나가기 @SubscribeMessage('leaveRoom') diff --git a/apps/chat/src/chat/chat.service.ts b/apps/chat/src/chat/chat.service.ts index 3a98bfaf..122a8682 100644 --- a/apps/chat/src/chat/chat.service.ts +++ b/apps/chat/src/chat/chat.service.ts @@ -23,6 +23,7 @@ export class ChatService { async createRoom(roomId: string, client: Socket) { const member = await this.memberService.getMemberInfo(client.token); + this.logger.log('createRoom... ', member.camperId, member.name); const newClient = new Client(member.camperId, member.name, client); const room = { @@ -39,11 +40,13 @@ export class ChatService { } async joinRoom(roomId: string, client: Socket) { + this.logger.log('client Token is.. ', client.token); const member = await this.memberService.getMemberInfo(client.token); const room = this.rooms.get(roomId); if (!room) new CustomWsException(ErrorStatus.ROOM_NOT_FOUND); + this.logger.log('createRoom... ', member.camperId, member.name); const newClient = new Client(member.camperId, member.name, client); room.clients.set(client.id, newClient); diff --git a/apps/client/src/components/ChatContainer/index.tsx b/apps/client/src/components/ChatContainer/index.tsx index f4ccb0d9..3425e3fb 100644 --- a/apps/client/src/components/ChatContainer/index.tsx +++ b/apps/client/src/components/ChatContainer/index.tsx @@ -32,26 +32,14 @@ const ChatContainer = ({ roomId, isProducer }: { roomId: string; isProducer: boo const setUpRoom = async (isProducer: boolean) => { try { if (isProducer) { - console.log('채팅룸 생성할거임'); - await new Promise(resolve => - socket?.emit( - 'createRoom', - { name: '송출자', camperId: 'J111', roomId: roomId }, - (response: { roomId: string }) => { - console.log(`채팅룸 생성 응답: ${JSON.stringify(response)}`); - console.log(`채팅룸 생성: ${response.roomId}`); - resolve; - }, - ), - ); + socket?.emit('createRoom', { roomId: roomId }, (response: { roomId: string }) => { + console.log(`채팅룸 생성: ${response.roomId}`); + }); } else { // 채팅방 입장 - await new Promise(resolve => - socket?.emit('joinRoom', { roomId: roomId, name: '김부캠', camperId: 'J999' }, () => { - console.log('채팅방 입장'); - resolve; - }), - ); + socket?.emit('joinRoom', { roomId: roomId }, () => { + console.log('채팅방 입장'); + }); } } catch (err) { console.error(`방 생성/입장 실패: ${err}`); diff --git a/apps/client/src/pages/Auth/index.tsx b/apps/client/src/pages/Auth/index.tsx index 8e193bc0..dbb043a0 100644 --- a/apps/client/src/pages/Auth/index.tsx +++ b/apps/client/src/pages/Auth/index.tsx @@ -12,12 +12,14 @@ function Auth() { useEffect(() => { try { const accessToken = searchParams.get('accessToken'); + const isNecessaryInfo = searchParams.get('isNecessaryInfo'); if (!accessToken) { throw new Error('액세스 토큰을 받지 못했습니다.'); } setLogIn(accessToken); - navigate('/', { replace: true }); + if (isNecessaryInfo === 'true') navigate('/', { replace: true }); + else navigate('/profile', { replace: true }); } catch (err) { setError(err instanceof Error ? err : new Error('로그인 처리 중 오류')); setTimeout(() => { diff --git a/apps/client/src/pages/Broadcast/BroadcastTitle.tsx b/apps/client/src/pages/Broadcast/BroadcastTitle.tsx index 9621a50c..a75d9296 100644 --- a/apps/client/src/pages/Broadcast/BroadcastTitle.tsx +++ b/apps/client/src/pages/Broadcast/BroadcastTitle.tsx @@ -26,7 +26,6 @@ function BroadcastTitle({ currentTitle, onTitleChange }: BroadcastTitleProps) { }; const onSubmit: SubmitHandler = data => { - // TODO: 요청 헤더에 Authorization 설정 axiosInstance.patch('/v1/broadcasts/title', { title: data.title }).then(response => { if (!response.data.success) { alert('제목 변경에 실패했습니다!'); diff --git a/apps/client/src/pages/Broadcast/index.tsx b/apps/client/src/pages/Broadcast/index.tsx index 43ef6c90..1641a6e3 100644 --- a/apps/client/src/pages/Broadcast/index.tsx +++ b/apps/client/src/pages/Broadcast/index.tsx @@ -21,6 +21,7 @@ import useScreenShare from '@/hooks/useScreenShare'; import BroadcastPlayer from './BroadcastPlayer'; import { Tracks } from '@/types/mediasoupTypes'; import RecordButton from './RecordButton'; +import axiosInstance from '@/services/axios'; const mediaServerUrl = import.meta.env.VITE_MEDIASERVER_URL; @@ -52,7 +53,7 @@ function Broadcast() { roomId, }); // 방송 정보 - const [title, setTitle] = useState('J000님의 방송'); + const [title, setTitle] = useState(''); const stopBroadcast = (e?: BeforeUnloadEvent) => { if (e) { @@ -76,9 +77,15 @@ function Broadcast() { }; useEffect(() => { - window.addEventListener('beforeunload', stopBroadcast); - tracksRef.current['mediaAudio'] = mediaStream?.getAudioTracks()[0]; + + axiosInstance.get('/v1/members/info').then(response => { + if (response.data.success) { + setTitle(`${response.data.data.camperId}님의 방송`); + } + }); + + window.addEventListener('beforeunload', stopBroadcast); return () => { window.removeEventListener('beforeunload', stopBroadcast); }; diff --git a/apps/client/src/pages/Home/LiveList.tsx b/apps/client/src/pages/Home/LiveList.tsx index a60f84c5..c79a95eb 100644 --- a/apps/client/src/pages/Home/LiveList.tsx +++ b/apps/client/src/pages/Home/LiveList.tsx @@ -43,7 +43,6 @@ function LiveList() { {liveList ? ( liveList.map(data => { const { broadcastId, broadcastTitle, camperId, profileImage, thumbnail } = data; - console.log(data); return (
diff --git a/apps/client/src/pages/Profile/EditUserInfo.tsx b/apps/client/src/pages/Profile/EditUserInfo.tsx index 268db17a..d612ea2d 100644 --- a/apps/client/src/pages/Profile/EditUserInfo.tsx +++ b/apps/client/src/pages/Profile/EditUserInfo.tsx @@ -14,7 +14,7 @@ interface EditUserInfoProps { interface FormInput { camperId: string | undefined; name: string | undefined; - type: Field | undefined; + field: Field | undefined; email: string | undefined; github: string | undefined; blog: string | undefined; @@ -31,7 +31,7 @@ function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { defaultValues: { camperId: userData?.camperId, name: userData?.name, - type: userData?.field, + field: userData?.field, email: userData?.contacts.email, github: userData?.contacts.github, blog: userData?.contacts.blog, @@ -47,7 +47,7 @@ function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { const formData = { name: data.name, camperId: data.camperId, - type: selectedField, + field: selectedField, contacts: { email: data.email ? data.email : '', github: data.github ? data.github : '', @@ -56,6 +56,8 @@ function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { }, }; + if (!formData.field) return; + axiosInstance.patch('/v1/members/info', formData).then(response => { if (response.data.success) { toggleEditing(); @@ -73,7 +75,7 @@ function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { MY
- {(errors.camperId || errors.name) && ( + {(errors.camperId || errors.name || !selectedField) && (

{errors.camperId ? errors.camperId.message : errors.name ? errors.name.message : '분야를 선택해주세요'}

diff --git a/apps/client/src/utils/utils.ts b/apps/client/src/utils/utils.ts index ae429998..9d3dc04c 100644 --- a/apps/client/src/utils/utils.ts +++ b/apps/client/src/utils/utils.ts @@ -11,3 +11,23 @@ export const checkDependencies = (functionName: string, dependencies: { [key: st if (missing.length === 0) return null; return new Error(`${functionName} Error: ${missing.join(',')}이(가) 없습니다.`); }; + +export const getPayloadFromJWT = () => { + const token = localStorage.getItem('accessToken'); + if (!token) return undefined; + const base64Payload = token.split('.')[1]; + const base64 = base64Payload.replace(/-/g, '+').replace(/_/g, '/'); + + const decodedJWT = JSON.parse( + decodeURIComponent( + window + .atob(base64) + .split('') + .map(c => { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(''), + ), + ); + return decodedJWT; +}; diff --git a/apps/media/src/sfu/services/record.service.ts b/apps/media/src/sfu/services/record.service.ts index dc4f4f1f..3e3ed107 100644 --- a/apps/media/src/sfu/services/record.service.ts +++ b/apps/media/src/sfu/services/record.service.ts @@ -16,7 +16,7 @@ export class RecordService { private readonly serverPrivateIp: string; private readonly announcedIp: string; - private transports = new Map(); + private transports = new Map(); constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { this.recordServerUrl = this.configService.get('RECORD_SERVER_URL', 'http://localhost:3003'); @@ -54,12 +54,15 @@ export class RecordService { } async sendStreamForRecord(room: mediasoup.types.Router, producers: mediasoup.types.Producer[]) { - const recordTransport = await this.createPlainTransport(room); - + const ports = { video: null, audio: null }; const consumers = await Promise.all( producers.map(async producer => { - this.transports.set(room.id, recordTransport); - + const recordTransport = await this.createPlainTransport(room); + if (this.transports.get(room.id)) { + this.transports.get(room.id).push(recordTransport); + } else { + this.transports.set(room.id, [recordTransport]); + } const rtpCapabilities = this.getRtpCapabilities(room, producer.kind); const recordConsumer = await recordTransport.consume({ producerId: producer.id, @@ -70,33 +73,32 @@ export class RecordService { temporalLayer: 1, }, }); - + const { port } = await this.getAvailablePort(); + ports[producer.kind] = port; this.setUpRecordConsumerListeners(recordConsumer); + await recordTransport.connect({ ip: this.serverPrivateIp, port }); + this.setUpRecordTransportListeners(recordTransport, port); return recordConsumer; }), ); + await this.sendStreamRequest(room.id, ports.video, STREAM_TYPE.RECORD, ports.audio); setTimeout(async () => { for (const consumer of consumers) { await consumer.resume(); await consumer.requestKeyFrame(); } }, 1000); - - const { port } = await this.getAvailablePort(); - - await recordTransport.connect({ ip: this.serverPrivateIp, port }); - this.setUpRecordTransportListeners(recordTransport, port); - - await this.sendStreamRequest(room.id, port, STREAM_TYPE.RECORD); } async stopStreamFromRecord(room: mediasoup.types.Router, title: string) { - const recordTransport = this.transports.get(room.id); - if (!recordTransport) { + const recordTransports = this.transports.get(room.id); + if (!recordTransports) { return; } - recordTransport.close(); + for (const recordTransport of recordTransports) { + recordTransport.close(); + } this.transports.delete(room.id); await lastValueFrom(this.httpService.post(`${this.apiServerUrl}/v1/records`, { title, roomId: room.id })); diff --git a/apps/record/src/ffmpeg.ts b/apps/record/src/ffmpeg.ts index d6b7d2ab..b66f3799 100644 --- a/apps/record/src/ffmpeg.ts +++ b/apps/record/src/ffmpeg.ts @@ -13,7 +13,7 @@ export const createFfmpegProcess = ( if (type === 'record') { fs.mkdirSync(`${assetsDirPath}/records/${roomId}`, { recursive: true }); } - const sdpString = audioPort ? createThumbnailSdpText(videoPort) : createThumbnailSdpText(videoPort); + const sdpString = audioPort ? createRecordSdpText(videoPort, audioPort) : createThumbnailSdpText(videoPort); const args = type === 'thumbnail' ? thumbnailArgs(assetsDirPath, roomId) : recordArgs(assetsDirPath, roomId); const ffmpegProcess = spawn('ffmpeg', args); @@ -61,22 +61,20 @@ a=rtcp-mux `; }; -// const createRecordSdpText = (videoPort: number, audioPort: number) => { -// return `v=0 -// o=- 0 0 IN IP4 127.0.0.1 -// s=FFmpeg -// c=IN IP4 127.0.0.1 -// t=0 0 -// m=video ${videoPort} RTP/AVP 101 -// a=rtpmap:101 VP8/90000 -// a=sendonly -// a=rtcp-mux -// m=audio ${audioPort} RTP/AVP 111 -// a=rtpmap:111 OPUS/48000/2 -// a=sendonly -// a=rtcp-mux -// `; -// }; +const createRecordSdpText = (videoPort: number, audioPort: number) => { + return `v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=FFmpeg +c=IN IP4 127.0.0.1 +t=0 0 +m=video ${videoPort} RTP/AVP 101 +a=rtpmap:101 VP8/90000 +a=sendonly +m=audio ${audioPort} RTP/AVP 100 +a=rtpmap:100 OPUS/48000/2 +a=sendonly +`; +}; const thumbnailArgs = (dirPath: string, roomId: string) => { const commandArgs = [ @@ -107,17 +105,16 @@ const recordArgs = (dirPath: string, roomId: string) => { 'sdp', // SDP 입력 포맷 '-i', 'pipe:0', // SDP를 파이프로 전달 - // HLS 스트리밍 저장 '-c:v', 'libx264', // 비디오 코덱 '-preset', - 'veryfast', // 빠른 인코딩 + 'slow', '-profile:v', 'high', // H.264 High 프로필 '-level:v', '4.1', // H.264 레벨 설정 (4.1) '-crf', - '23', // 비디오 품질 설정 + '1', // 비디오 품질 설정 '-c:a', 'libmp3lame', // 오디오 코덱 '-b:a', @@ -131,7 +128,7 @@ const recordArgs = (dirPath: string, roomId: string) => { '-f', 'hls', // HLS 출력 포맷 '-hls_time', - '6', // 각 세그먼트 길이 (초) + '15', // 각 세그먼트 길이 (초) '-hls_list_size', '0', // 유지할 세그먼트 개수 '-hls_segment_filename', diff --git a/docker-compose.yml b/docker-compose.yml index 78e278e6..fd6c6a9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,8 @@ services: - REDIS_PORT=${REDIS_PORT} - REDIS_CHAT=${REDIS_CHAT} - JWT_SECRET=${JWT_SECRET} + - API_SERVER_URL=${API_SERVER_URL} + - HTTP_TIMEOUT=${HTTP_TIMEOUT} record: container_name: record-camon