Skip to content
Closed
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
@@ -0,0 +1,44 @@
package com.mr.domain.learning.controller;

import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO;
import com.mr.domain.learning.dto.res.LearningProgressResponseDTO;
import com.mr.domain.learning.dto.res.LearningResultResponseDTO;
import com.mr.domain.learning.service.LearningService;
import com.mr.global.apipayload.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/learnings")
public class LearningController {

private final LearningService learningService;

// 학습 결과 저장
@PostMapping("/{learningId}/result")
public ApiResponse<LearningResultResponseDTO.SaveResultResultDTO> saveLearningResult(
@PathVariable Long learningId, // 임시
@Valid @RequestBody LearningResultSaveRequestDTO.SaveResultDTO request
){
LearningResultResponseDTO.SaveResultResultDTO response =
learningService.saveResult(learningId, request);

return ApiResponse.onSuccess(response);
}

// 학습 진행률 조회
@GetMapping("/{learningId}/progress")
public ApiResponse<LearningProgressResponseDTO.ProgressResultDTO> getLearningProgress(
@PathVariable Long learningId
) {
LearningProgressResponseDTO.ProgressResultDTO result = learningService.getLearningProgress(learningId);
return ApiResponse.onSuccess(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.mr.domain.learning.dto.req;

import jakarta.validation.constraints.NotNull;

public class LearningResultSaveRequestDTO {
public record SaveResultDTO(
@NotNull(message = "점수는 필수 입력값입니다.")
Integer score
) {}
Comment on lines +5 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

점수 범위를 요청 단계에서 제한해 주세요.

현재 @NotNull만 있어 음수나 100 초과 점수가 저장될 수 있고, 이후 score >= 90 조건으로 완료 처리됩니다. 점수 범위가 0~100이라면 @Min(0)@Max(100)을 추가해 잘못된 학습 결과를 차단해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java`
around lines 5 - 9, Update the SaveResultDTO.score validation in
LearningResultSaveRequestDTO to enforce the valid 0–100 range by adding minimum
and maximum constraints alongside `@NotNull`, preserving the existing
required-field message.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

학습 결과 저장의 식별 단위를 단계별로 통일해야 합니다.

현재 UserLearningProgress(user, learningStep) 단위인데, 요청과 서비스는 (user, learning) 단위로 처리합니다. 이 불일치 때문에 신규 저장은 null 단계로 실패하고, 여러 단계 저장 시 단건 조회가 비유일해지며, 진행률 집계에서는 저장된 결과가 제외됩니다.

  • src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java#L5-L9: learningStepId를 요청에 추가하거나, 엔티티를 학습 단위 모델로 재설계하세요.
  • src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java#L77-L81: 필수 learningStep이 null이면 Custom Exception으로 즉시 거부하세요.
  • src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java#L12-L17: 유저·학습·단계 기준으로 단건 조회하고, 동일한 단계 기준으로 완료 수를 집계하세요.
  • src/main/java/com/mr/domain/learning/service/LearningService.java#L47-L56: 단계 존재 여부와 학습 소속을 검증한 뒤 단계가 포함된 조회/생성 경로를 사용하세요.
📍 Affects 4 files
  • src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java#L5-L9 (this comment)
  • src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java#L77-L81
  • src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java#L12-L17
  • src/main/java/com/mr/domain/learning/service/LearningService.java#L47-L56
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java`
around lines 5 - 9, 학습 결과 저장의 식별 단위를 user·learning·learningStep으로 통일하세요.
src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java:5-9의
SaveResultDTO에 learningStepId를 추가하고,
src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java:77-81에서는
필수 learningStep 누락을 Custom Exception으로 거부하세요.
src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java:12-17의
조회·완료 집계를 user·learning·learningStep 기준으로 변경하고,
src/main/java/com/mr/domain/learning/service/LearningService.java:47-56에서 단계 존재
및 학습 소속을 검증한 뒤 단계 포함 조회·생성 경로를 사용하세요.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.mr.domain.learning.dto.res;

public class LearningProgressResponseDTO {

public record ProgressResultDTO(
Long learningId,
Integer progressRate
){
public static ProgressResultDTO of(Long learningId, Integer progressRate){
return new ProgressResultDTO(learningId, progressRate);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.mr.domain.learning.dto.res;

import com.mr.domain.learning.entity.UserLearningProgress;

import java.time.LocalDateTime;

public class LearningResultResponseDTO {
public record SaveResultResultDTO(
Long userLearningProgressId,
Long userId,
Long learningId,
String status,
Integer score,
LocalDateTime completedAt
) {
public static SaveResultResultDTO from(UserLearningProgress progress) {
return new SaveResultResultDTO(
progress.getId(),
progress.getUser().getUserId(),
progress.getLearning().getId(),
progress.getLearningStatus(),
progress.getScore(),
progress.getUpdatedAt()
);
Comment on lines +8 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

completedAt에 일반 수정 시각을 넣지 마세요.

IN_PROGRESS 결과를 저장해도 progress.getUpdatedAt()completedAt으로 반환됩니다. 실제 완료 시각을 제공하려면 완료 상태로 전환되는 순간만 별도 필드에 기록하고, 그렇지 않다면 응답 필드를 lastStudiedAt 또는 updatedAt으로 변경해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java`
around lines 8 - 24, Update SaveResultResultDTO.from so completedAt is populated
only from a dedicated completion timestamp recorded when the learning status
transitions to completed; otherwise rename the response field to lastStudiedAt
or updatedAt and map progress.getUpdatedAt() to that field, avoiding use of
updatedAt as completedAt.

}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.mr.domain.learning.entity;

import com.mr.domain.learning.exception.LearningErrorStatus;
import com.mr.domain.user.entity.User;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseTimeEntity;
import jakarta.persistence.Column;
Expand Down Expand Up @@ -41,8 +42,9 @@ public class UserLearningProgress extends BaseTimeEntity {
private Long id;

// 유저 아이디
@Column(name = "user_id", nullable = false)
private Long userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

// 학습 아이디
@ManyToOne(fetch = FetchType.LAZY)
Expand All @@ -63,20 +65,20 @@ public class UserLearningProgress extends BaseTimeEntity {
private LocalDateTime lastStudiedAt;

@Builder(access = AccessLevel.PRIVATE)
private UserLearningProgress(Long userId, Learning learning, LearningStep learningStep,
private UserLearningProgress(User user, Learning learning, LearningStep learningStep,
Integer score, LocalDateTime lastStudiedAt) {
this.userId = userId;
this.user = user;
this.learning = learning;
this.learningStep = learningStep;
this.score = score;
this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now();
}

public static UserLearningProgress create(Long userId, Learning learning, LearningStep learningStep) {
public static UserLearningProgress create(User user, Learning learning, LearningStep learningStep) {
validateLearningAndStep(learning, learningStep);

return UserLearningProgress.builder()
.userId(userId)
.user(user)
.learning(learning)
.learningStep(learningStep)
.lastStudiedAt(LocalDateTime.now())
Expand All @@ -98,4 +100,12 @@ public void updateProgress(Integer score, LocalDateTime lastStudiedAt) {
this.score = score;
this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now();
}

// 점수 기준 학습 진행 상태 파악
public String getLearningStatus() {
if (this.score == null) {
return "NOT_STARTED";
}
return this.score >= 90 ? "COMPLETED" : "RETRY";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
@AllArgsConstructor
public enum LearningErrorStatus implements BaseCode {

INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다.");
INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다."),
LEARNING_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_01", "해당 학습(Learning)을 찾을 수 없습니다.");

private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.mr.domain.learning.repository;

import com.mr.domain.learning.entity.Learning;
import org.springframework.data.jpa.repository.JpaRepository;

public interface LearningRepository extends JpaRepository<Learning, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.mr.domain.learning.repository;

import com.mr.domain.learning.entity.LearningStep;
import org.springframework.data.jpa.repository.JpaRepository;

public interface LearningStepRepository extends JpaRepository<LearningStep, Long> {
// 특정 학습에 속한 전체 단계 수 조회
long countByLearningId(Long learningId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mr.domain.learning.repository;

import com.mr.domain.learning.entity.UserLearningProgress;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface UserLearningProgressRepository extends JpaRepository<UserLearningProgress, Long> {

Optional<UserLearningProgress> findByUserIdAndLearningId(Long userId, Long learningId);
// 유저가 완료한(점수 80점 이상) 학습 단계 수 조회

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

완료 기준 주석을 실제 기준과 통일하세요.

주석은 80점 이상이라고 설명하지만 쿼리와 getLearningStatus()는 90점 이상을 사용합니다. 현재 기준인 90점으로 수정하고, 가능하면 임계값을 한 곳에서 관리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java`
at line 13, Update the completion-criteria comment in
UserLearningProgressRepository to state that completed stages require a score of
90 or higher, matching the query and getLearningStatus(). If practical within
the existing design, centralize the completion threshold so these checks reuse
one managed value instead of duplicating 90.

@Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " +
"JOIN ulp.learningStep ls " +
"WHERE ulp.user.id = :userId AND ls.learning.id = :learningId AND ulp.score >= 90")
Comment on lines +12 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
USER_FILE=$(fd -a '^User\.java$' src/main/java | head -n 1)
rg -n '`@Id`|class User|private .* (id|userId)' "$USER_FILE"
rg -n 'findByUserIdAndLearningId|ulp\.user\.id' src/main/java

Repository: Musereview/BE

Length of output: 736


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository target =="
cat -n src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java | sed -n '1,80p'

user_file="$(fd -a '^User\.java$' src/main/java | head -n 1)"
echo "== user model =="
cat -n "$user_file" | sed -n '1,220p'

echo "== progress model =="
progress_file="$(fd -a '^UserLearningProgress\.java$' src/main/java | head -n 1)"
cat -n "$progress_file" | sed -n '1,220p'

echo "== service usage =="
cat -n src/main/java/com/mr/domain/learning/service/LearningService.java | sed -n '1,120p'

echo "== JPA method resolution semantics evidence in code =="
python3 - <<'PY'
from pathlib import Path
import re

user_file = next(Path('src/main/java').rglob('User.java'))
progress_file = next(Path('src/main/java').rglob('UserLearningProgressRepository.java'))
for path in [user_file, progress_file]:
    text = path.read_text()
    print(f"-- {path} --")
    for name in ['userId', 'user.setId', '.setId', 'setUserId']:
        print(name, '=>' , 'YES' if name in text else 'NO')
PY

Repository: Musereview/BE

Length of output: 13369


🌐 Web query:

Spring Data JPA property expression derived query methods User.id versus User.userId query property id

💡 Result:

In Spring Data JPA, derived query methods map directly to the property names defined in your entity class [1]. The discrepancy between User.id and User.userId often arises from a misunderstanding of how the framework parses these names [2][1]. 1. Mapping Rules: When you use a method like findByUserId, Spring Data JPA parses the string after By and looks for an exact matching property name (e.g., userId) within your User entity [1]. If your entity has a field named id but you attempt to use findByUserId, the framework will throw a PropertyReferenceException because it cannot find a property named userId [2]. 2. Identifier Property (id): The property marked with @Id is recognized as the entity's identifier [3][4]. Reserved repository methods like findById are hardcoded to target this identifier property, regardless of its name [3][4]. If you have both an @Id property (e.g., pk) and a separate property named id, findById will query by pk, not by the id property [3][4]. 3. Best Practices: - Ensure method names match your entity field names exactly (case-sensitive camelCase) [1]. - If you have an entity property named userId, use findByUserId [2]. - If you have an entity property named id, use findById to invoke the standard JPA lookup, or if you need to query the id property explicitly and avoid collision with the reserved findById, use a descriptive name like findUserById [3][4]. - To resolve ambiguities in property traversal or to be explicit, you can use underscores (_) as separators, though this is primarily for nested properties [5][6]. In summary, Spring Data JPA does not automatically map userId to id; you must match your repository method name to the actual field name present in your entity class [2][1].

Citations:


🌐 Web query:

Spring Data Commons Path property id shorthand JDOqlReservedWords RESERVED "id"

💡 Result:

In Spring Data Commons, the term "id" does not function as a shorthand for all identifier properties in property paths, nor is it a globally reserved word in the context of JDOQL (JDO Query Language) [1][2][3]. Spring Data Commons In Spring Data repositories, repository methods like findById are reserved keywords specifically bound to the entity's identifier property (defined by @Id) [1]. This behavior is independent of the actual property name (e.g., if your primary key field is named pk, findById will still target pk) [1]. Conversely, if you have a non-identifier property explicitly named "id", repository methods like findUserById are used to distinguish them from the identifier-specific reserved methods [1]. Spring Data also supports type-safe property paths (e.g., via generated classes like Person_ or TypedPropertyPath) to avoid fragile string-based paths [4][5]. JDOQL (JDO Query Language) "id" is not listed as a reserved word in the JDO specification [2][3]. JDOQL is a Java-based query language that operates on persistent objects [2][3]. Reserved words in JDOQL are typically language keywords (like SELECT, FROM, WHERE, etc.) and the "this" keyword, which refers to the current element being evaluated [2][6][3]. While you may encounter identifier properties named "id" in your domain model, they are treated as standard properties and are not inherently reserved by the JDOQL syntax [7][8].

Citations:


🌐 Web query:

Spring Framework PathExpressionResolver JDOQLReservedWords ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND

💡 Result:

Based on a comprehensive review of the Spring Framework, JDOQL, and DataNucleus documentation, there is no public API, constant, or identifier known as ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND within the Spring Framework or the official JDO/JDOQL specifications [1][2][3][4]. While the Spring Framework extensively uses terms like "property path" (e.g., in PropertyPathFactoryBean and PropertyAccessorUtils) [2][5][6], and JDOQL provides object-based query capabilities [1][7], the specific string provided does not appear to be a recognized technical term or constant in these ecosystems. It is possible that this string originated from a proprietary or non-public internal codebase, a legacy third-party library, or a misunderstanding of internal compiler logic. If you are encountering this string in a specific error message or legacy codebase, it may refer to an internal implementation detail used by a parser or compiler (such as DataNucleus's JDOQLCompiler) to distinguish reserved keywords from valid property paths [8][9][10]. In typical JDOQL usage, identifiers such as "this" are reserved, and property paths are traversed using dot notation (e.g., this.property.field) [7][11].

Citations:


🌐 Web query:

site:github spring-projects/spring-data-commons Path JDOQLReservedWords ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND

💡 Result:

The identifier ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND does not appear in the provided search results for the spring-projects/spring-data-commons repository. However, the provided documentation confirms that Spring Data repositories use reserved method names that target the identifier property (the field annotated with @Id) regardless of the property name [1][2]. Methods such as findById(ID id), existsById(ID id), and deleteById(ID id) are reserved and automatically map to the entity's primary key [3][2]. If an entity has a property named id that is not the primary key, a derived query method like findById(Long id) will still target the actual identifier (primary key) property rather than the field named id [2]. To target a specific property named id that is not the primary key, developers are advised to use a descriptive token in the method name—for example, findUserById(Long id)—to avoid collision with these reserved methods [2]. If this is not possible, the @Query annotation can be used to define a custom query [1].

Citations:


🌐 Web query:

site:github spring-projects/spring-data-commons ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND PathExpressionResolver

💡 Result:

Based on the provided search results, there is no evidence of a class named PathExpressionResolver or a constant named ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND within the spring-projects/spring-data-commons repository. The search results show that while Spring Framework contains classes like PathResourceResolver [1][2] for handling resource paths, and Symfony's PropertyAccess component utilizes a PropertyPath class [3] for path manipulation, the specific identifier and class name provided do not appear in official Spring Data Commons documentation or codebase references found in the search.

Citations:


🌐 Web query:

"ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND"

💡 Result:

The term ID_IS_RESERVED_FOR_PROPERTY_PATH_SHORTHAND does not appear to be a standard, universally recognized programming term, language specification, or error message across major software ecosystems. Research indicates that it is likely an internal identifier, constant, or diagnostic message specific to a particular compiler, transpiler, or data processing library—most likely one involved in handling JavaScript/TypeScript object property shorthand syntax [1][2] or complex property path resolution [3][4]. In the context of JavaScript tooling (such as Babel or Next.js compilers), internal logic often distinguishes between standard object properties and "shorthand" properties (e.g., { id } vs { id: id }) [1][2]. Compilers and transpilers may use internal flags or restricted identifiers to ensure these properties are processed correctly, especially when performing code transformations, scope renaming, or compiling server-side actions [1][5][2]. If you are encountering this string in an error log or code base: 1. It is likely a diagnostic artifact from a build tool or compiler plugin. 2. It suggests that a variable name or property key named id is colliding with an internal mechanism reserved for processing property path shortcuts or shorthand object syntax [1][5][6]. 3. To resolve such errors, you may need to rename the property (e.g., change id to entityId) to avoid conflicts with the framework's internal reserved namespaces [5][7][8]. For authoritative guidance, search the specific GitHub repository or documentation for the software framework (e.g., Next.js, Babel, or a specific database client) where this string appears, as it is almost certainly implementation-specific [1][5][2][8].

Citations:


사용자 연관관계의 실제 식별자 경로를 맞춰 주세요.

User.userId@Id지만, JPA 쿼리/파서에서 ulp.user.id는 실제 필드명 userId와 달라서 WHERE ulp.user.userId = :userId로 명시하는 편이 안전합니다. 파생 메서드는 findByUser_UUserIdAndLearning_Id(...)처럼 실제 User.userId, Learning.id 경로에 맞춰 정리하면 나중에 이 이관관계가 바뀌어도 식별자 식별에 혼동이 덜 생깁니다. Spring Data JPA 문서의 Property Expressions 참고하면 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java`
around lines 12 - 16, User 식별자 경로가 엔티티의 실제 필드명과 일치하지 않습니다.
UserLearningProgressRepository의 findByUserIdAndLearningId 파생 메서드를 User.userId와
Learning.id를 명시하는 중첩 프로퍼티 경로로 변경하고, 같은 리포지토리의 `@Query에서도` ulp.user.id를
ulp.user.userId로 수정하세요. 기존 메서드 인자와 조회 동작은 유지하세요.

long countCompletedStepsByUserIdAndLearningId(@Param("userId") Long userId, @Param("learningId") Long learningId);
}
82 changes: 82 additions & 0 deletions src/main/java/com/mr/domain/learning/service/LearningService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.mr.domain.learning.service;

import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO;
import com.mr.domain.learning.dto.res.LearningProgressResponseDTO;
import com.mr.domain.learning.dto.res.LearningResultResponseDTO;
import com.mr.domain.learning.entity.Learning;
import com.mr.domain.learning.entity.UserLearningProgress;
import com.mr.domain.learning.exception.LearningErrorStatus;
import com.mr.domain.learning.repository.LearningRepository;
import com.mr.domain.learning.repository.LearningStepRepository;
import com.mr.domain.learning.repository.UserLearningProgressRepository;
import com.mr.domain.user.entity.User;
import com.mr.domain.user.exception.UserErrorStatus;
import com.mr.domain.user.repository.UserRepository;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.security.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Comment on lines +23 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate file =="
fd -a 'LearningService.java' . || true

echo "== file outline/stat =="
if [ -f src/main/java/com/mr/domain/learning/service/LearningService.java ]; then
  wc -l src/main/java/com/mr/domain/learning/service/LearningService.java
  ast-grep outline src/main/java/com/mr/domain/learning/service/LearningService.cpp --view compact 2>/dev/null || true
  ast-grep outline src/main/java/com/mr/domain/learning/service/LearningService.java --view expanded || true
fi

echo "== relevant file sections =="
sed -n '1,220p' src/main/java/com/mr/domain/learning/service/LearningService.java | cat -n

echo "== related persistence classes references =="
rg -n "class .*Progress|interface .*Progress|create\\(|saveResult|readOnly|Transactional" src/main/java/com/mr -S || true

Repository: Musereview/BE

Length of output: 8805


쓰기 메서드에서 readOnly 트랜잭션을 상속하지 않게 해주세요.

클래스 수준 @Transactional(readOnly = true)saveResult()에도 적용됩니다. 기존 UserLearningProgress 엔티티의 변경 사항이 dirty checking으로 인식되어도 readOnly = true 설정상 flush되지 않아 데이터와 집계 상태가 서로 다른 상태로 남을 수 있습니다. saveResult()에는 @Transactional을 명시하고 조회 메서드에만 readOnly = true를 유지하세요. Spring 문서의 readOnly 설정을 참고하면 도움이 됩니다. 👍

권장 수정
+    `@Transactional`
     public LearningResultResponseDTO.SaveResultResultDTO saveResult(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/mr/domain/learning/service/LearningService.java` around
lines 23 - 25, Update LearningService so the write method saveResult()
explicitly uses a read-write `@Transactional` configuration, preventing
inheritance of the class-level readOnly transaction. Keep
`@Transactional`(readOnly = true) on the class or apply it only to query methods,
while preserving read-only behavior for those queries.

public class LearningService {

private final UserLearningProgressRepository userLearningProgressRepository;
private final LearningStepRepository learningStepRepository;
private final LearningRepository learningRepository;
// 임시 작명
private final UserRepository userRepository;

// 학습 결과 저장
public LearningResultResponseDTO.SaveResultResultDTO saveResult(
Long learningId,
LearningResultSaveRequestDTO.SaveResultDTO request
){
Long userId = SecurityUtil.getCurrentUserId();

User user = userRepository.findById(userId)
.orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND));

Learning learning = learningRepository.findById(learningId)
.orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND));

UserLearningProgress progress = userLearningProgressRepository
.findByUserIdAndLearningId(userId, learning.getId())
.map(p -> {
p.updateProgress(request.score(), LocalDateTime.now());
return p;
})
.orElseGet(() -> {
UserLearningProgress newProgress = UserLearningProgress.create(user, learning, null);
newProgress.updateProgress(request.score(), LocalDateTime.now());
return userLearningProgressRepository.save(newProgress);
});
return LearningResultResponseDTO.SaveResultResultDTO.from(progress);
}

// 학습 진행률 조회 로직
public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress(Long learningId) {
// 학습 존재 여부 확인
if (!learningRepository.existsById(learningId)) {
throw new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND);
}

// 현재 로그인한 유저 ID 획득 (SecurityUtil 활용)
Long userId = SecurityUtil.getCurrentUserId();

// 전체 학습 단계 수 조회
long totalStepCount = learningStepRepository.countByLearningId(learningId);

// 완료한 학습 단계 수 조회
long completedStepCount = userLearningProgressRepository.countCompletedStepsByUserIdAndLearningId(userId, learningId);

// 진행률 계산 (0으로 나누기 예외 방지)
int progressRate = totalStepCount == 0 ? 0 : (int) Math.round((double) completedStepCount / totalStepCount * 100);

return LearningProgressResponseDTO.ProgressResultDTO.of(learningId, progressRate);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public enum UserErrorStatus implements BaseCode {

NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."),
NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."),
PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다.");


private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.mr.domain.user.repository;

import com.mr.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}
Loading