Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
}, {
"name" : "User Consents",
"description" : "개인정보처리/이용약관/마케팅 동의 제출·이력·철회 (append-only audit)."
}, {
"name" : "Public: Shared Feedback",
"description" : "공유 링크로 피드백 조회(비인증)"
}, {
"name" : "Resumes",
"description" : "이력서 업로드/관리. 업로드 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (DOC_STATE) 로 통지됨."
Expand Down Expand Up @@ -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" ],
Expand Down Expand Up @@ -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" ],
Expand Down Expand Up @@ -2543,6 +2631,14 @@
}
}
},
"ShareResponse" : {
"type" : "object",
"properties" : {
"shareToken" : {
"type" : "string"
}
}
},
"ResumeResponse" : {
"type" : "object",
"properties" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "지원하지 않는 음성 형식입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public interface SessionFeedbackRepository extends JpaRepository<SessionFeedback

Optional<SessionFeedback> findBySession_Id(Long sessionId);

Optional<SessionFeedback> findByShareToken(String shareToken);

boolean existsBySession_Id(Long sessionId);

@Query("""
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 피드백 공개 공유 토큰. null = 비공개. "공유" 시 UUID 발급.
ALTER TABLE session_feedbacks
ADD COLUMN share_token VARCHAR(64) UNIQUE;
2 changes: 2 additions & 0 deletions frontend/src/app/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/share/:token', element: <SharedFeedbackPage /> },
{ path: '/auth/callback', element: <AuthCallbackPage /> },
{
path: '/workspace',
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/features/feedback/api/feedbackApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,16 @@ export type Feedback = S['FeedbackResponse']
export async function getFeedback(sessionId: number): Promise<Feedback> {
return (await apiClient.get<Feedback>(`/api/sessions/${sessionId}/feedback`)).data
}

// 공유 토큰 발급(멱등). 토큰 문자열 반환.
export async function enableShare(sessionId: number): Promise<string> {
const res = await apiClient.post<{ shareToken?: string }>(
`/api/sessions/${sessionId}/feedback/share`,
)
return res.data.shareToken ?? ''
}

// 공개 토큰으로 피드백 조회(비인증).
export async function getSharedFeedback(token: string): Promise<Feedback> {
return (await apiClient.get<Feedback>(`/api/public/feedbacks/${token}`)).data
}
8 changes: 7 additions & 1 deletion frontend/src/features/feedback/index.ts
Original file line number Diff line number Diff line change
@@ -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'
18 changes: 16 additions & 2 deletions frontend/src/features/feedback/model/useFeedback.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
})
}
29 changes: 27 additions & 2 deletions frontend/src/features/feedback/ui/FeedbackReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null)
const [downloading, setDownloading] = useState(false)

Expand All @@ -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 (
<div className="flex w-full flex-col gap-4">
<div className="flex justify-end">
<div className="flex justify-end gap-2">
{shareable && (
<Button
variant="secondary"
onClick={handleShare}
disabled={share.isPending}
>
{copied ? '링크 복사됨!' : share.isPending ? '공유 준비 중…' : '공유'}
</Button>
)}
<Button variant="secondary" onClick={handleDownload} disabled={downloading}>
{downloading ? 'PDF 생성 중…' : 'PDF 다운로드'}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function SessionFeedbackPage() {
</div>
)}

{data && <FeedbackReport feedback={data} />}
{data && <FeedbackReport feedback={data} shareable />}
</main>
<SiteFooter />
</div>
Expand Down
Loading