diff --git a/backend/openapi.json b/backend/openapi.json index a315d2bd..e6867da9 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -47,6 +47,9 @@ }, { "name" : "User Consents", "description" : "개인정보처리/이용약관/마케팅 동의 제출·이력·철회 (append-only audit)." + }, { + "name" : "Public: Shared Feedback", + "description" : "공유 링크로 피드백 조회(비인증)" }, { "name" : "Resumes", "description" : "이력서 업로드/관리. 업로드 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (DOC_STATE) 로 통지됨." @@ -652,6 +655,54 @@ } } }, + "/api/sessions/{sessionId}/feedback/share" : { + "post" : { + "tags" : [ "Session Feedback" ], + "summary" : "피드백 공유 토큰 발급(멱등)", + "operationId" : "shareSessionFeedback", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "공유 토큰", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ShareResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ShareResponse" + } + } + } + }, + "404" : { + "description" : "세션 또는 피드백 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ShareResponse" + } + } + } + } + } + } + }, "/api/resumes" : { "get" : { "tags" : [ "Resumes" ], @@ -2010,6 +2061,43 @@ } } }, + "/api/public/feedbacks/{shareToken}" : { + "get" : { + "tags" : [ "Public: Shared Feedback" ], + "summary" : "공유 토큰으로 피드백 조회", + "operationId" : "getSharedFeedback", + "parameters" : [ { + "name" : "shareToken", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "피드백", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + }, + "404" : { + "description" : "공유된 피드백 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + } + } + } + }, "/api/internal/users/{userId}/github-token" : { "get" : { "tags" : [ "Internal: GitHub Token Delegation" ], @@ -2543,6 +2631,14 @@ } } }, + "ShareResponse" : { + "type" : "object", + "properties" : { + "shareToken" : { + "type" : "string" + } + } + }, "ResumeResponse" : { "type" : "object", "properties" : { diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 18136f9b..78054335 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -34,6 +34,7 @@ public enum ApiErrorCode { SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다."), SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "세션에 접근할 수 없습니다."), FEEDBACK_NOT_READY(HttpStatus.NOT_FOUND, "피드백이 아직 생성되지 않았습니다."), + FEEDBACK_NOT_FOUND(HttpStatus.NOT_FOUND, "공유된 피드백을 찾을 수 없습니다."), VOICE_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일을 업로드할 수 없습니다."), VOICE_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "음성 파일 크기가 너무 큽니다."), VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."), diff --git a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java index 7bb5619a..b5313dd3 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java @@ -64,7 +64,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v3/api-docs/**", "/api/swagger-ui/**", "/api/swagger-ui.html", - "/actuator/**" + "/actuator/**", + "/api/public/**" ).permitAll() .requestMatchers("/api/internal/**").hasAuthority(InternalApiKeyAuthenticationFilter.AUTHORITY) .requestMatchers("/api/**").authenticated() diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java index 1f62eb4e..e3f2015b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java @@ -6,6 +6,7 @@ import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.SessionFeedback; import com.stackup.stackup.session.domain.SessionFeedbackRepository; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,4 +26,21 @@ public FeedbackResult get(Long userId, Long sessionId) { .orElseThrow(() -> new DomainException(ApiErrorCode.FEEDBACK_NOT_READY)); return FeedbackResult.of(feedback); } + + // 공유 활성화: 소유자 검증 후 토큰 보장(없으면 발급). 멱등 — 현재 토큰 반환. + @Transactional + public String enableShare(Long userId, Long sessionId) { + sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + SessionFeedback feedback = feedbackRepository.findBySession_Id(sessionId) + .orElseThrow(() -> new DomainException(ApiErrorCode.FEEDBACK_NOT_READY)); + return feedback.enableShare(UUID.randomUUID().toString()); + } + + // 공개 조회(비인증): 공유 토큰으로만. 없으면 404. + public FeedbackResult getByToken(String shareToken) { + SessionFeedback feedback = feedbackRepository.findByShareToken(shareToken) + .orElseThrow(() -> new DomainException(ApiErrorCode.FEEDBACK_NOT_FOUND)); + return FeedbackResult.of(feedback); + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java index a1d2d6d2..b5f1330e 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java @@ -55,6 +55,10 @@ public class SessionFeedback extends BaseSoftDeleteEntity { @Column(name = "report_file_path", length = 1000) private String reportFilePath; + // 공개 공유 토큰. null = 비공개. 공유 활성화 시 1회 발급(이후 유지). + @Column(name = "share_token", length = 64, unique = true) + private String shareToken; + private SessionFeedback(InterviewSession session, Double overallScore, Double technicalAccuracy, Double logicScore, Double communicationScore, String strengthsSummary, String weaknessesSummary, @@ -82,4 +86,12 @@ public static SessionFeedback of(InterviewSession session, Double overallScore, communicationScore, strengthsSummary, weaknessesSummary, improvementKeywordsJson, reportFilePath); } + + // 공유 토큰을 보장(없으면 발급)하고 현재 토큰 반환. 멱등. + public String enableShare(String token) { + if (this.shareToken == null) { + this.shareToken = token; + } + return this.shareToken; + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java index 09a44fd7..cf29e29c 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java @@ -11,6 +11,8 @@ public interface SessionFeedbackRepository extends JpaRepository findBySession_Id(Long sessionId); + Optional findByShareToken(String shareToken); + boolean existsBySession_Id(Long sessionId); @Query(""" diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/PublicFeedbackController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/PublicFeedbackController.java new file mode 100644 index 00000000..860c64d3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/PublicFeedbackController.java @@ -0,0 +1,33 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.session.application.SessionFeedbackQueryService; +import com.stackup.stackup.session.presentation.dto.FeedbackResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +// 공유 토큰으로 피드백을 조회하는 공개(비인증) 엔드포인트. /api/public/** 는 permitAll. +@Tag(name = "Public: Shared Feedback", description = "공유 링크로 피드백 조회(비인증)") +@RestController +@RequestMapping("/api/public/feedbacks") +@RequiredArgsConstructor +public class PublicFeedbackController { + + private final SessionFeedbackQueryService queryService; + + @Operation(operationId = "getSharedFeedback", summary = "공유 토큰으로 피드백 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "피드백"), + @ApiResponse(responseCode = "404", description = "공유된 피드백 없음") + }) + @GetMapping("/{shareToken}") + public FeedbackResponse get(@PathVariable String shareToken) { + return FeedbackResponse.from(queryService.getByToken(shareToken)); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java index b0513e94..8a6c1172 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java @@ -11,6 +11,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -35,4 +36,21 @@ public FeedbackResponse get( ) { return FeedbackResponse.from(queryService.get(principal.userId(), sessionId)); } + + @Operation(operationId = "shareSessionFeedback", summary = "피드백 공유 토큰 발급(멱등)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "공유 토큰"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 또는 피드백 없음") + }) + @PostMapping("/share") + public ShareResponse share( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return new ShareResponse(queryService.enableShare(principal.userId(), sessionId)); + } + + public record ShareResponse(String shareToken) { + } } diff --git a/backend/src/main/resources/db/migration/V11__add_share_token_to_feedbacks.sql b/backend/src/main/resources/db/migration/V11__add_share_token_to_feedbacks.sql new file mode 100644 index 00000000..56c59e1f --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__add_share_token_to_feedbacks.sql @@ -0,0 +1,3 @@ +-- 피드백 공개 공유 토큰. null = 비공개. "공유" 시 UUID 발급. +ALTER TABLE session_feedbacks + ADD COLUMN share_token VARCHAR(64) UNIQUE; diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx index 0255590d..ffdc4f54 100644 --- a/frontend/src/app/router/index.tsx +++ b/frontend/src/app/router/index.tsx @@ -8,10 +8,12 @@ import InterviewSetupPage from '@/pages/InterviewSetup' import InterviewSessionPage from '@/pages/InterviewSession' import SessionFeedbackPage from '@/pages/SessionFeedback' import HistoryPage from '@/pages/History' +import SharedFeedbackPage from '@/pages/SharedFeedback' export const router = createBrowserRouter([ { path: '/', element: }, { path: '/login', element: }, + { path: '/share/:token', element: }, { path: '/auth/callback', element: }, { path: '/workspace', diff --git a/frontend/src/features/feedback/api/feedbackApi.ts b/frontend/src/features/feedback/api/feedbackApi.ts index f24b4586..4c424a0b 100644 --- a/frontend/src/features/feedback/api/feedbackApi.ts +++ b/frontend/src/features/feedback/api/feedbackApi.ts @@ -7,3 +7,16 @@ export type Feedback = S['FeedbackResponse'] export async function getFeedback(sessionId: number): Promise { return (await apiClient.get(`/api/sessions/${sessionId}/feedback`)).data } + +// 공유 토큰 발급(멱등). 토큰 문자열 반환. +export async function enableShare(sessionId: number): Promise { + const res = await apiClient.post<{ shareToken?: string }>( + `/api/sessions/${sessionId}/feedback/share`, + ) + return res.data.shareToken ?? '' +} + +// 공개 토큰으로 피드백 조회(비인증). +export async function getSharedFeedback(token: string): Promise { + return (await apiClient.get(`/api/public/feedbacks/${token}`)).data +} diff --git a/frontend/src/features/feedback/index.ts b/frontend/src/features/feedback/index.ts index 28059d14..a6539712 100644 --- a/frontend/src/features/feedback/index.ts +++ b/frontend/src/features/feedback/index.ts @@ -1,3 +1,9 @@ export { FeedbackReport } from './ui/FeedbackReport' -export { useFeedback, isFeedbackPending, feedbackKeys } from './model/useFeedback' +export { + useFeedback, + isFeedbackPending, + feedbackKeys, + useShareFeedback, + useSharedFeedback, +} from './model/useFeedback' export type { Feedback } from './api/feedbackApi' diff --git a/frontend/src/features/feedback/model/useFeedback.ts b/frontend/src/features/feedback/model/useFeedback.ts index c832b94e..00e73026 100644 --- a/frontend/src/features/feedback/model/useFeedback.ts +++ b/frontend/src/features/feedback/model/useFeedback.ts @@ -1,6 +1,6 @@ -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { isApiError } from '@/shared/api' -import { getFeedback } from '../api/feedbackApi' +import { enableShare, getFeedback, getSharedFeedback } from '../api/feedbackApi' export const feedbackKeys = { all: ['feedback'] as const, @@ -21,3 +21,17 @@ export function useFeedback(sessionId: number) { retryDelay: 3000, }) } + +// 공유 토큰 발급(버튼 클릭). +export function useShareFeedback(sessionId: number) { + return useMutation({ mutationFn: () => enableShare(sessionId) }) +} + +// 공개 페이지: 공유 토큰으로 피드백 조회(비인증, 재시도 없음). +export function useSharedFeedback(token: string) { + return useQuery({ + queryKey: [...feedbackKeys.all, 'shared', token], + queryFn: () => getSharedFeedback(token), + retry: false, + }) +} diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index 5ae47897..b373bbcf 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -2,10 +2,19 @@ import { useRef, useState } from 'react' import { StatusBadge } from '@/shared/ui/StatusBadge' import { ScoreBar } from '@/shared/ui/ScoreBar' import { Button } from '@/shared/ui/Button' +import { useCopyToClipboard } from '@/shared/hooks' import type { Feedback } from '../api/feedbackApi' import { downloadElementAsPdf } from '../lib/downloadPdf' +import { useShareFeedback } from '../model/useFeedback' -export function FeedbackReport({ feedback }: { feedback: Feedback }) { +// shareable: 소유자 화면에서만 '공유' 버튼 노출(공개 페이지에선 false). +export function FeedbackReport({ + feedback, + shareable = false, +}: { + feedback: Feedback + shareable?: boolean +}) { const reportRef = useRef(null) const [downloading, setDownloading] = useState(false) @@ -19,10 +28,26 @@ export function FeedbackReport({ feedback }: { feedback: Feedback }) { } } + const share = useShareFeedback(feedback.sessionId ?? 0) + const { copy, copied } = useCopyToClipboard() + const handleShare = async () => { + const token = await share.mutateAsync() + if (token) await copy(`${window.location.origin}/share/${token}`) + } + const overall = feedback.overallScore return (
-
+
+ {shareable && ( + + )} diff --git a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx index c28399f1..5abc9035 100644 --- a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx +++ b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx @@ -38,7 +38,7 @@ export default function SessionFeedbackPage() {
)} - {data && } + {data && }
diff --git a/frontend/src/pages/SharedFeedback/index.ts b/frontend/src/pages/SharedFeedback/index.ts new file mode 100644 index 00000000..84411ab6 --- /dev/null +++ b/frontend/src/pages/SharedFeedback/index.ts @@ -0,0 +1 @@ +export { default } from './ui/SharedFeedbackPage' diff --git a/frontend/src/pages/SharedFeedback/ui/SharedFeedbackPage.tsx b/frontend/src/pages/SharedFeedback/ui/SharedFeedbackPage.tsx new file mode 100644 index 00000000..8aa672da --- /dev/null +++ b/frontend/src/pages/SharedFeedback/ui/SharedFeedbackPage.tsx @@ -0,0 +1,32 @@ +import { useParams } from 'react-router-dom' +import { SiteNav } from '@/widgets/site-nav' +import { SiteFooter } from '@/widgets/site-footer' +import { FeedbackReport, useSharedFeedback } from '@/features/feedback' + +// 공유 토큰으로 피드백을 보는 공개(비로그인) 페이지. +export default function SharedFeedbackPage() { + const { token } = useParams<{ token: string }>() + const { data, isLoading, isError } = useSharedFeedback(token ?? '') + + return ( +
+ +
+

공유된 면접 피드백

+ + {isLoading && ( +

불러오는 중…

+ )} + + {isError && ( +

+ 유효하지 않거나 만료된 공유 링크입니다. +

+ )} + + {data && } +
+ +
+ ) +} diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 89e5ae2b..58cd0cd5 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -141,6 +141,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/sessions/{sessionId}/feedback/share": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 피드백 공유 토큰 발급(멱등) */ + post: operations["shareSessionFeedback"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/resumes": { parameters: { query?: never; @@ -591,6 +608,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/public/feedbacks/{shareToken}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 공유 토큰으로 피드백 조회 */ + get: operations["getSharedFeedback"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/internal/users/{userId}/github-token": { parameters: { query?: never; @@ -820,6 +854,9 @@ export interface components { /** Format: int32 */ sequenceNumber?: number; }; + ShareResponse: { + shareToken?: string; + }; ResumeResponse: { /** Format: int64 */ id?: number; @@ -1615,6 +1652,46 @@ export interface operations { }; }; }; + shareSessionFeedback: { + parameters: { + query?: never; + header?: never; + path: { + sessionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 공유 토큰 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ShareResponse"]; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ShareResponse"]; + }; + }; + /** @description 세션 또는 피드백 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ShareResponse"]; + }; + }; + }; + }; listResumes: { parameters: { query?: never; @@ -2821,6 +2898,37 @@ export interface operations { }; }; }; + getSharedFeedback: { + parameters: { + query?: never; + header?: never; + path: { + shareToken: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 피드백 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["FeedbackResponse"]; + }; + }; + /** @description 공유된 피드백 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["FeedbackResponse"]; + }; + }; + }; + }; internalFetchGithubToken: { parameters: { query?: never; diff --git a/frontend/src/shared/hooks/index.ts b/frontend/src/shared/hooks/index.ts index 970ef857..02a5dea3 100644 --- a/frontend/src/shared/hooks/index.ts +++ b/frontend/src/shared/hooks/index.ts @@ -3,3 +3,4 @@ export type { UseTypewriterOptions } from './useTypewriter' export { useEventStream } from './useEventStream' export { useAnalysisProgress, analysisProgress } from './useAnalysisProgress' export type { AnalysisProgress } from './useAnalysisProgress' +export { useCopyToClipboard } from './useCopyToClipboard' diff --git a/frontend/src/shared/hooks/useCopyToClipboard.ts b/frontend/src/shared/hooks/useCopyToClipboard.ts new file mode 100644 index 00000000..0cb9032f --- /dev/null +++ b/frontend/src/shared/hooks/useCopyToClipboard.ts @@ -0,0 +1,20 @@ +import { useState } from 'react' + +// 텍스트를 클립보드로 복사하고 2초간 copied 상태를 유지. +export function useCopyToClipboard(resetMs = 2000) { + const [copied, setCopied] = useState(false) + + const copy = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), resetMs) + return true + } catch { + setCopied(false) + return false + } + } + + return { copy, copied } +}