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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
Expand All @@ -11,6 +12,7 @@

import java.io.IOException;

@Slf4j
@Component
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

Expand All @@ -21,9 +23,13 @@ public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationF
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {

// 상세 에러는 서버 로그로만 기록 (보안)
log.error("OAuth2 authentication failed: {}", exception.getMessage());

// 프론트에는 일반화된 에러 코드만 전달
String targetUrl = UriComponentsBuilder.fromUriString(frontendUrl)
.path("/")
.queryParam("error", exception.getLocalizedMessage())
.queryParam("error", "OAUTH_LOGIN_FAILED")
.build().toUriString();

getRedirectStrategy().sendRedirect(request, response, targetUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ List<Problem> findProblemsByTagAndLevelRange(@org.apache.ibatis.annotations.Para

List<Problem> findProblemsByNumbers(
@org.apache.ibatis.annotations.Param("problemNumbers") List<String> problemNumbers);

/**
* 여러 문제의 태그를 한 번에 조회 (N+1 쿼리 방지)
* @param problemNumbers 문제 번호 목록
* @return 문제번호-태그 쌍 목록
*/
List<ProblemTag> findTagsByProblemNumbers(
@org.apache.ibatis.annotations.Param("problemNumbers") List<String> problemNumbers);
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public List<ProblemRecommendationResponse> getCurriculumProblems(Long studyId) {
scoredTags.sort(Comparator.comparing(ScoredTag::score).reversed());
List<ScoredTag> candidateTags = scoredTags.stream().limit(10).toList();

// 3. 제외할 문제 ID 수집 (이미 푼 문제, 미션 문제)
// 3. 제외할 문제 ID 수집 (현재 미션에 포함된 문제 - 중복 추천 방지)
List<java.util.Set<String>> memberSolvedSets = members.stream()
.map(m -> algorithmRecordRepository.findSolvedProblemNumbers(m.getId()))
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ public void toggleSos(Long missionId, Integer problemId, Long userId) {

/**
* 스터디의 미션 목록 조회 (algorithm_records와 자동 동기화)
* 최적화: N+1 쿼리를 방지하기 위해 배치 페칭(Batch Fetching) 적용.
* 수정: 동기화 후 최신 데이터 조회하여 정합성 보장
*/
public List<MissionWithProgressResult> getMissions(Long studyId, Long requestUserId) {
List<StudyMission> missions = missionRepository.findByStudyIdOrderByWeekDesc(studyId);
Expand All @@ -294,47 +294,44 @@ public List<MissionWithProgressResult> getMissions(Long studyId, Long requestUse
}

List<User> members = userRepository.findByStudyId(studyId);
List<Long> missionIds = missions.stream().map(StudyMission::getId)
.collect(java.util.stream.Collectors.toList());

// 모든 제출 내역을 한 번에 가져오기 (Batch Fetch)
List<StudyMissionSubmission> allSubmissions = submissionRepository.findByMissionIds(missionIds);

// MissionID -> UserID -> List<Submission> (또는 Map<ProblemId, Submission>) 형태로
// 그룹화
// Map<MissionId, Map<UserId, List<Submission>>>
java.util.Map<Long, java.util.Map<Long, List<StudyMissionSubmission>>> submissionMap = allSubmissions.stream()
.collect(java.util.stream.Collectors.groupingBy(
StudyMissionSubmission::getMissionId,
java.util.stream.Collectors.groupingBy(StudyMissionSubmission::getUserId)));

List<MissionWithProgressResult> result = new ArrayList<>();

for (StudyMission mission : missions) {
List<Integer> problemIds = parseProblems(mission.getProblemIds());
int totalProblems = problemIds.size();

// 문제 상세 정보 조회
// 문제 상세 정보 조회 (N+1 쿼리 방지: 배치 조회)
List<MissionProblemInfo> problems = new ArrayList<>();
if (!problemIds.isEmpty()) {
List<String> problemNumberStrs = problemIds.stream().map(String::valueOf).toList();
List<Problem> problemEntities = problemMapper.findProblemsByNumbers(problemNumberStrs);

// [FIX] 한 번에 모든 태그 조회 후 Map으로 그룹화
List<com.ssafy.dash.problem.domain.ProblemTag> allTags = problemMapper.findTagsByProblemNumbers(problemNumberStrs);
java.util.Map<String, List<String>> tagsByProblem = allTags.stream()
.collect(java.util.stream.Collectors.groupingBy(
com.ssafy.dash.problem.domain.ProblemTag::getProblemNumber,
java.util.stream.Collectors.mapping(
com.ssafy.dash.problem.domain.ProblemTag::getTagKey,
java.util.stream.Collectors.toList())));

for (Problem p : problemEntities) {
List<String> tags = problemMapper.findTagsByProblemNumber(p.getProblemNumber())
List<String> tags = tagsByProblem.getOrDefault(p.getProblemNumber(), List.of())
.stream()
.map(tagService::getKoreanName)
.toList();
problems.add(new MissionProblemInfo(p.getProblemNumber(), p.getTitle(), p.getLevel(), tags));
}
}

// 현재 미션에 대한 제출 내역 가져오기
java.util.Map<Long, List<StudyMissionSubmission>> missionSubmissions = submissionMap
.getOrDefault(mission.getId(), new java.util.HashMap<>());

// algorithm_records와 동기화 (누락된 완료 상태 업데이트)
// [FIX] 1. 먼저 동기화 수행 (algorithm_records -> submissions DB 업데이트)
syncSubmissionsWithRecords(mission.getId(), members, problemIds);

// [FIX] 2. 동기화 후 최신 데이터 조회
List<StudyMissionSubmission> missionSubmissionList = submissionRepository.findByMissionId(mission.getId());
java.util.Map<Long, List<StudyMissionSubmission>> missionSubmissions = missionSubmissionList.stream()
.collect(java.util.stream.Collectors.groupingBy(StudyMissionSubmission::getUserId));

List<StudyMissionSubmission> mySubmissions = missionSubmissions.getOrDefault(requestUserId,
new ArrayList<>());
int solvedCount = (int) mySubmissions.stream().filter(s -> Boolean.TRUE.equals(s.getCompleted())).count();
Expand Down Expand Up @@ -398,6 +395,7 @@ public List<MissionWithProgressResult> getMissions(Long studyId, Long requestUse
return result;
}


/**
* algorithm_records와 study_mission_submissions 동기화
* - Dashboard와 동일한 데이터 소스 기반으로 통일
Expand Down
86 changes: 32 additions & 54 deletions backend/src/main/resources/mapper/problem/ProblemMapper.xml
Original file line number Diff line number Diff line change
@@ -1,67 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ssafy.dash.problem.infrastructure.persistence.ProblemMapper">

<insert id="saveProblem">
INSERT INTO problems (problem_number, title, level, class, essential, sprout)
VALUES (#{problemNumber}, #{title}, #{level}, #{problemClass}, #{essential}, #{sprout})
ON DUPLICATE KEY UPDATE
title = VALUES(title),
level = VALUES(level),
class = VALUES(class),
essential = VALUES(essential),
sprout = VALUES(sprout)
</insert>
<insert id="saveProblem"> INSERT INTO problems (problem_number, title, level, class, essential,
sprout) VALUES (#{problemNumber}, #{title}, #{level}, #{problemClass}, #{essential},
#{sprout}) ON DUPLICATE KEY UPDATE title = VALUES(title), level = VALUES(level), class =
VALUES(class), essential = VALUES(essential), sprout = VALUES(sprout) </insert>

<insert id="saveProblemTag" useGeneratedKeys="true" keyProperty="id">
INSERT INTO problem_tags (problem_number, tag_key)
VALUES (#{problemNumber}, #{tagKey})
ON DUPLICATE KEY UPDATE id = id
</insert>
<insert id="saveProblemTag" useGeneratedKeys="true" keyProperty="id"> INSERT INTO problem_tags
(problem_number, tag_key) VALUES (#{problemNumber}, #{tagKey}) ON DUPLICATE KEY UPDATE id =
id </insert>

<delete id="deleteProblemTags">
DELETE FROM problem_tags WHERE problem_number = #{problemNumber}
</delete>
<delete id="deleteProblemTags"> DELETE FROM problem_tags WHERE problem_number = #{problemNumber} </delete>

<select id="findProblemByNumber" resultType="Problem">
SELECT problem_number, title, level, class as problemClass, essential, sprout
FROM problems WHERE problem_number = #{problemNumber}
</select>
<select id="findProblemByNumber" resultType="Problem"> SELECT problem_number, title, level,
class as problemClass, essential, sprout FROM problems WHERE problem_number =
#{problemNumber} </select>

<select id="countProblems" resultType="int">
SELECT COUNT(*) FROM problems
</select>
<select id="countProblems" resultType="int"> SELECT COUNT(*) FROM problems </select>

<select id="findTagsByProblemNumber" resultType="string">
SELECT tag_key
FROM problem_tags
WHERE problem_number = #{problemNumber}
</select>
<select id="findTagsByProblemNumber" resultType="string"> SELECT tag_key FROM problem_tags WHERE
problem_number = #{problemNumber} </select>

<select id="findProblemsByTagAndLevelRange" resultType="Problem">
SELECT p.problem_number, p.title, p.level, p.class as problemClass, p.essential, p.sprout
FROM problems p
JOIN problem_tags pt ON p.problem_number = pt.problem_number
WHERE pt.tag_key = #{tagKey}
AND p.level BETWEEN #{minLevel} AND #{maxLevel}
<if test="excludedIds != null and !excludedIds.isEmpty()">
AND p.problem_number NOT IN
<foreach item="id" collection="excludedIds" open="(" separator="," close=")">
#{id}
</foreach>
<select id="findProblemsByTagAndLevelRange" resultType="Problem"> SELECT p.problem_number,
p.title, p.level, p.class as problemClass, p.essential, p.sprout FROM problems p JOIN
problem_tags pt ON p.problem_number = pt.problem_number WHERE pt.tag_key = #{tagKey} AND
p.level BETWEEN #{minLevel} AND #{maxLevel} <if
test="excludedIds != null and !excludedIds.isEmpty()"> AND p.problem_number NOT IN <foreach
item="id" collection="excludedIds" open="(" separator="," close=")"> #{id} </foreach>
</if>
ORDER BY p.essential DESC, RAND()
LIMIT 20
ORDER BY p.essential DESC, RAND() LIMIT 20 </select>

<select id="findProblemsByNumbers" resultType="Problem"> SELECT problem_number, title, level,
class as problemClass, essential, sprout FROM problems WHERE problem_number IN <foreach
item="num" collection="problemNumbers" open="(" separator="," close=")"> #{num} </foreach>
</select>

<select id="findProblemsByNumbers" resultType="Problem">
SELECT problem_number, title, level, class as problemClass, essential, sprout
FROM problems
WHERE problem_number IN
<foreach item="num" collection="problemNumbers" open="(" separator="," close=")">
#{num}
</foreach>
<!-- 여러 문제의 태그를 한 번에 조회 (N+1 쿼리 방지) -->
<select id="findTagsByProblemNumbers" resultType="ProblemTag"> SELECT problem_number as
problemNumber, tag_key as tagKey FROM problem_tags WHERE problem_number IN <foreach
item="num" collection="problemNumbers" open="(" separator="," close=")"> #{num} </foreach>
</select>

</mapper>
</mapper>
8 changes: 3 additions & 5 deletions frontend/src/components/study/StudyMissionSidebarDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<!-- 해결됨 (클릭 시 대시보드로 이동하여 코드 보기) -->
<div v-if="isSolved(member, pid)" class="flex justify-center">
<button
@click="goToDashboard(pid, member.userId)"
@click="goToDashboard(pid)"
class="w-5 h-5 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center hover:bg-emerald-200 hover:scale-110 transition-all"
title="코드 보러 가기"
>
Expand Down Expand Up @@ -361,14 +361,12 @@ const submitAddProblem = async () => {
}
};

const goToDashboard = (problemId, userId) => {
// 관리자가 아닌 일반 유저의 대시보드 경로는 /dashboard 입니다.
const goToDashboard = (problemId) => {
// 문제 필터링을 위해 대시보드로 이동
router.push({
path: '/dashboard',
query: {
problemNumber: problemId,
userId: userId,
// drawer: 'true' // 추후 상세 뷰 바로 열기 지원 시 활성화
}
});
};
Expand Down
75 changes: 56 additions & 19 deletions frontend/src/views/dashboard/DashboardView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,13 @@
<!-- 타임라인 섹션 -->
<div>
<!-- 타임라인 헤더 -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-slate-800 flex items-center gap-2">
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="text-xl font-bold text-slate-800 flex items-center gap-2 shrink-0">
<Activity :size="20" class="text-brand-500 fill-brand-500"/>
타임라인
</h2>
<!-- 필터 탭 -->
<div class="flex p-1 bg-slate-200/50 rounded-xl font-bold">
<div class="flex p-1 bg-slate-200/50 rounded-xl font-bold shrink-0">
<button
v-for="filter in ['ALL', 'MISSION', 'MOCK_EXAM', 'DEFENSE', 'GENERAL']"
:key="filter"
Expand All @@ -195,24 +195,39 @@
</button>
</div>

<!-- 검색창 -->
<div class="relative w-full max-w-xs">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search :size="14" class="text-slate-400" />
</div>
<input
v-model="searchQuery"
type="text"
placeholder="문제 번호, 제목 검색..."
class="w-full pl-9 pr-4 py-2 bg-slate-100 border-none rounded-xl text-sm font-bold text-slate-700 placeholder:text-slate-400 focus:ring-2 focus:ring-brand-200 focus:bg-white transition-all"
/>
<button
v-if="searchQuery"
@click="searchQuery = ''"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600"
<!-- 확장 가능한 검색창 -->
<div
class="relative transition-all duration-300 ease-out"
:class="isSearchFocused || searchQuery ? 'w-64' : 'w-10'"
>
<button
v-if="!isSearchFocused && !searchQuery"
@click="focusSearch"
class="w-10 h-10 flex items-center justify-center bg-slate-100 hover:bg-slate-200 rounded-xl transition-colors"
>
<X :size="14" />
<Search :size="16" class="text-slate-500" />
</button>
<div v-else class="relative w-full">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search :size="14" class="text-slate-400" />
</div>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
placeholder="문제 번호, 제목 검색..."
class="w-full pl-9 pr-8 py-2 bg-slate-100 border-none rounded-xl text-sm font-bold text-slate-700 placeholder:text-slate-400 focus:ring-2 focus:ring-brand-200 focus:bg-white transition-all"
@focus="isSearchFocused = true"
@blur="handleSearchBlur"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600"
>
<X :size="14" />
</button>
</div>
</div>
</div>

Expand Down Expand Up @@ -783,6 +798,28 @@ const showSuccessOnly = ref(false);
const groupByProblem = ref(false);
const expandedGroups = ref(new Set());
const searchQuery = ref('');
const isSearchFocused = ref(false);
const searchInputRef = ref(null);

// 검색창 확장/축소 함수
const focusSearch = () => {
isSearchFocused.value = true;
nextTick(() => {
searchInputRef.value?.focus();
});
};

const handleSearchBlur = () => {
// searchQuery가 비어있을 때만 축소
if (!searchQuery.value) {
isSearchFocused.value = false;
}
};

const clearSearch = () => {
searchQuery.value = '';
isSearchFocused.value = false;
};

// 날짜 네비게이션 함수
const goToPrevDate = () => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/study/StudyMissionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,11 @@
</span>
</div>

<!-- 1등 완주자 -->
<!-- 완주자 -->
<div v-if="getFirstCompleter(mission)" class="flex items-center gap-1.5">
<span class="text-[10px] font-bold text-slate-400">·</span>
<span class="px-2 py-0.5 bg-yellow-50 text-yellow-600 rounded-lg text-xs font-bold flex items-center gap-1">
👑 {{ getFirstCompleter(mission) }}
{{ getFirstCompleter(mission) }}
</span>
</div>

Expand Down