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 @@ -14,6 +14,13 @@ public record AdminPracticeLogResponseDto(
Long point,
LocalDateTime createdAt
) {
/**
* {@code point} 는 저장된 값이 아니라 미션 타입의 정가다.
*
* <p>{@code mission_log.point} 는 "이 기록으로 자동 적립이 돌았는가"를 담게 되어, 미션을 받을 수 있는
* 앱에서 온 요청의 행은 0 이다(#318). 이 화면이 보여 주는 값의 뜻은 "복습 세트 완료 하나의 값어치"라
* 적립 여부와 무관하므로, 화면이 지금까지 보여 주던 값을 그대로 유지한다.
*/
public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNote practiceNote) {
return new AdminPracticeLogResponseDto(
missionLog.getId(),
Expand All @@ -22,7 +29,7 @@ public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNo
missionLog.getUser().getEmail(),
missionLog.getReferenceId(),
practiceNote != null ? practiceNote.getTitle() : "-",
missionLog.getPoint(),
missionLog.getMissionType().getPoint(),
missionLog.getCreatedAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,38 @@ public class MissionLog extends BaseEntity {

private MissionType missionType;

/**
* 이 기록으로 자동 적립 경로가 열렸는가. 열렸으면 그 행동의 정가, 아니면 0 이다.
*
* <p>예전에는 지급 여부와 무관하게 언제나 정가가 들어갔다. 미션을 받을 수 있는 앱에서 온 요청은
* 자동 적립을 돌리지 않는데도 정가가 들어가는 바람에, 하루 200점 상한({@code getPointSumToday})이
* 받지도 않은 점수로 채워져 같은 계정의 구버전 기기 적립까지 막았다(#318).
*
* <p>상한 계산은 이 값의 합만 본다. 그래서 <b>실제로 적립이 돈 행만 상한을 갉는다.</b>
* 적립이 돈 행에는 상한에 걸려 깎이기 전 정가를 그대로 넣는다. 깎인 실지급액을 넣으면
* 상한 계산이 달라져 구버전만 쓰는 사용자의 동작이 바뀐다.
*
* <p>관리자 복습 로그 화면이 보여 주는 점수는 이 값이 아니라 미션 타입의 정가다
* ({@code AdminPracticeLogResponseDto}). 화면의 뜻은 "이 행동의 값어치"라 적립 여부와 무관하다.
*/
private Long point;

private Long referenceId;

/** 자동 적립이 도는 요청에서 남기는 기록. 정가가 그대로 들어간다. */
public static MissionLog from(MissionRegisterDto missionRegisterDto, User user) {
return from(missionRegisterDto, user, true);
}

/**
* @param accrued 이 요청에서 자동 적립이 도는가. 돌지 않으면 {@link #point} 를 0 으로 남겨
* 하루 상한과 중복 방지 판정이 이 행을 "적립된 행"으로 세지 않게 한다.
*/
public static MissionLog from(MissionRegisterDto missionRegisterDto, User user, boolean accrued) {
return MissionLog.builder()
.user(user)
.missionType(missionRegisterDto.missionType())
.point(missionRegisterDto.missionType().getPoint())
.point(accrued ? missionRegisterDto.missionType().getPoint() : 0L)
.referenceId(missionRegisterDto.referenceId())
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,44 @@
import java.util.List;
import java.util.Map;

/**
* 중복 방지 판정에 붙는 {@code accruedOnly} 는 "실제로 적립된 행만 셀 것인가"다.
*
* <p>{@code mission_log} 행은 미션을 받을 수 있는 앱에서 온 요청도 그대로 남긴다. 관리자 통계와
* 훈장이 이 테이블만 보기 때문이다. 그런데 그 행을 중복 방지에서도 그대로 세면, 같은 계정을 구버전
* 기기에서도 쓰는 사용자는 구버전 활동이 "이미 적립했다"로 막혀 적립으로도 진행도로도 남지 않는다(#318).
*
* <p>그래서 <b>자동 적립이 도는 요청은 적립된 행만 세고</b>({@code accruedOnly = true}),
* 진행도만 올리는 요청은 지금처럼 모든 행을 센다({@code accruedOnly = false}).
* 뒤쪽을 함께 풀면 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다.
*/
public interface MissionLogRepositoryCustom {
boolean alreadyWriteProblemsTodayMoreThan3(Long userId);

boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly);

long countProblemWritesToday(Long userId);

long countProblemWritesToday(Long userId, boolean accruedOnly);

boolean alreadyPracticeProblem(Long problemId);

boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly);

boolean alreadyPracticeNote(Long practiceNoteId);

boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly);

boolean alreadyLogin(Long userId);

boolean alreadyLogin(Long userId, boolean accruedOnly);

/**
* 오늘 자동 적립으로 잡힌 점수 합. 하루 200점 상한이 이 값을 본다.
*
* <p>적립이 돌지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다.
* 조건을 따로 걸지 않는 이유는 그 0 이 이미 "이 행은 적립되지 않았다"를 뜻하기 때문이다.
*/
Long getPointSumToday(Long userId);

Map<LocalDate, Long> getDailyActiveUsersCount(int days);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,28 @@ public MissionLogRepositoryImpl(EntityManager entityManager) {

@Override
public boolean alreadyWriteProblemsTodayMoreThan3(Long userId) {
return countProblemWritesToday(userId) >= 3;
return alreadyWriteProblemsTodayMoreThan3(userId, false);
}

@Override
public boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly) {
return countProblemWritesToday(userId, accruedOnly) >= 3;
}

@Override
public long countProblemWritesToday(Long userId) {
return countProblemWritesToday(userId, false);
}

@Override
public long countProblemWritesToday(Long userId, boolean accruedOnly) {
Long count = queryFactory
.select(missionLog.count())
.from(missionLog)
.where(missionLog.missionType.eq(MissionType.PROBLEM_WRITE)
.and(missionLog.user.id.eq(userId))
.and(createdToday())
.and(accrued(accruedOnly))
)
.fetchOne();

Expand All @@ -48,41 +59,73 @@ public long countProblemWritesToday(Long userId) {

@Override
public boolean alreadyPracticeProblem(Long problemId){
return alreadyPracticeProblem(problemId, false);
}

@Override
public boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly){

return queryFactory
.selectOne()
.from(missionLog)
.where(missionLog.missionType.eq(MissionType.PROBLEM_PRACTICE)
.and(missionLog.referenceId.eq(problemId))
.and(createdToday())
.and(accrued(accruedOnly))
)
.fetchFirst() != null;
}

@Override
public boolean alreadyPracticeNote(Long practiceNoteId){
return alreadyPracticeNote(practiceNoteId, false);
}

@Override
public boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly){
return queryFactory
.selectOne()
.from(missionLog)
.where(missionLog.missionType.eq(MissionType.NOTE_PRACTICE)
.and(missionLog.referenceId.eq(practiceNoteId))
.and(createdToday())
.and(accrued(accruedOnly))
)
.fetchFirst() != null;
}

@Override
public boolean alreadyLogin(Long userId){
return alreadyLogin(userId, false);
}

@Override
public boolean alreadyLogin(Long userId, boolean accruedOnly){
return queryFactory
.selectOne()
.from(missionLog)
.where(missionLog.missionType.eq(MissionType.USER_LOGIN)
.and(missionLog.user.id.eq(userId))
.and(createdToday())
.and(accrued(accruedOnly))
)
.fetchFirst() != null;
}

/**
* "실제로 적립된 행만" 조건. {@code accruedOnly} 가 아니면 조건을 붙이지 않는다.
*
* <p>{@code null} 을 돌려주면 QueryDSL 이 그 항을 통째로 빼므로, 기존 판정의 쿼리가 그대로 남는다.
* 적립된 행은 {@code point} 에 정가가 들어 있고, 적립이 돌지 않은 요청의 행은 0 이다
* ({@code MissionLog.point}).
*
* <p>옛 행은 적립 여부와 무관하게 정가가 들어 있어 전부 "적립된 행"으로 잡힌다.
* 덜 주는 쪽이 아니라 지금과 같게 두는 쪽이라 안전하다.
*/
private BooleanExpression accrued(boolean accruedOnly) {
return accruedOnly ? missionLog.point.gt(0L) : null;
}

@Override
public Long getPointSumToday(Long userId){
Long result = queryFactory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@
* 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정도 설정과 무관하게 돈다.
* <b>자동 적립이 도는 요청에서는 미션 진행도가 오르지 않는다.</b> 같은 행동으로 적립과 미션 보상을
* 둘 다 받지 않게 하기 위해서다. 진행도를 막는 것은 {@link MissionProgressUpdater} 가 한다.
*
* <p><b>중복 방지는 요청이 어느 경로인가에 따라 보는 범위가 다르다.</b> 자동 적립이 도는 요청은
* <b>실제로 적립된 행만</b> 보고, 진행도를 올리는 요청은 지금처럼 모든 행을 본다
* ({@code MissionLogRepositoryCustom} 의 {@code accruedOnly}).
*
* <p>가르는 이유는 같은 계정을 두 기기에서 쓰는 사용자 때문이다(#318). 신버전 요청이 남긴 행을
* 구버전 요청의 중복 방지가 그대로 세면, 그 활동은 적립으로도(행에 막혀서) 진행도로도
* (구버전이라 막혀서) 남지 않아 <b>어느 쪽으로도 XP 가 들어오지 않는다.</b> 반대로 양쪽 판정을 함께 풀면
* 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오르므로, 푸는 것은 적립 쪽 하나뿐이다.
*
* <p>대신 두 기기를 번갈아 쓰면 <b>같은 대상에 대해 한 번은 적립, 한 번은 진행도가 따로 잡힌다.</b>
* 기기마다 따로 한 활동이라 요청 하나가 두 경로로 받는 것은 아니다. 활동을 통째로 잃는 것보다 낫다고 보고
* 받아들인 값이다.
*/
@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -92,7 +105,8 @@ private User lockUser(Long userId) {

public void registerLoginMission(Long userId) {
User user = lockUser(userId);
boolean alreadyLogin = missionLogRepository.alreadyLogin(userId);
boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest();
boolean alreadyLogin = missionLogRepository.alreadyLogin(userId, accrues);

if(!alreadyLogin) {
MissionRegisterDto missionRegisterDto = MissionRegisterDto
Expand All @@ -101,7 +115,7 @@ public void registerLoginMission(Long userId) {
.missionType(MissionType.USER_LOGIN)
.build();

MissionLog missionLog = MissionLog.from(missionRegisterDto, user);
MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues);
missionLogRepository.save(missionLog);

addPointToUser(user, missionLog);
Expand All @@ -114,7 +128,9 @@ public void registerLoginMission(Long userId) {

public void registerProblemWriteMission(Long userId) {
User user = lockUser(userId);
boolean alreadyWriteMoreThanThreeProblems = missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId);
boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest();
boolean alreadyWriteMoreThanThreeProblems =
missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId, accrues);

if(!alreadyWriteMoreThanThreeProblems) {
MissionRegisterDto missionRegisterDto = MissionRegisterDto
Expand All @@ -123,7 +139,7 @@ public void registerProblemWriteMission(Long userId) {
.missionType(MissionType.PROBLEM_WRITE)
.build();

MissionLog missionLog = MissionLog.from(missionRegisterDto, user);
MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues);
missionLogRepository.save(missionLog);

addPointToUser(user, missionLog);
Expand All @@ -132,7 +148,8 @@ public void registerProblemWriteMission(Long userId) {

public void registerProblemWriteMissionBatch(Long userId, int count) {
User user = lockUser(userId);
long todayCount = missionLogRepository.countProblemWritesToday(userId);
boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest();
long todayCount = missionLogRepository.countProblemWritesToday(userId, accrues);
int toCreate = (int) Math.min(count, Math.max(0, 3 - todayCount));
if (toCreate == 0) return;

Expand All @@ -142,15 +159,16 @@ public void registerProblemWriteMissionBatch(Long userId, int count) {
.build();

for (int i = 0; i < toCreate; i++) {
MissionLog log = MissionLog.from(dto, user);
MissionLog log = MissionLog.from(dto, user, accrues);
missionLogRepository.save(log);
addPointToUser(user, log);
}
}

public void registerProblemPracticeMission(Long userId, Long problemId) {
User user = lockUser(userId);
boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId);
boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest();
boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId, accrues);

if(!alreadyPracticeProblem) {
MissionRegisterDto missionRegisterDto = MissionRegisterDto
Expand All @@ -160,7 +178,7 @@ public void registerProblemPracticeMission(Long userId, Long problemId) {
.referenceId(problemId)
.build();

MissionLog missionLog = MissionLog.from(missionRegisterDto, user);
MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues);
missionLogRepository.save(missionLog);

addPointToUser(user, missionLog);
Expand All @@ -169,7 +187,8 @@ public void registerProblemPracticeMission(Long userId, Long problemId) {

public void registerNotePracticeMission(Long userId, Long practiceNoteId) {
User user = lockUser(userId);
boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId);
boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest();
boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId, accrues);

if(!alreadyPracticeNote) {
MissionRegisterDto missionRegisterDto = MissionRegisterDto
Expand All @@ -179,7 +198,7 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) {
.referenceId(practiceNoteId)
.build();

MissionLog missionLog = MissionLog.from(missionRegisterDto, user);
MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues);
missionLogRepository.save(missionLog);

addPointToUser(user, missionLog);
Expand All @@ -197,7 +216,8 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) {
* 중복 방지 판정은 그대로 돈다. 진행도 증가는 반대로 적립하는 요청에서만 막힌다.
*
* <p>하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 {@link MissionRewardGranter} 가
* 따로 지급하고 상한을 타지 않는다.
* 따로 지급하고 상한을 타지 않는다. 상한이 보는 {@code getPointSumToday} 는 적립이 돈 행만 센다.
* 적립하지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다.
*/
private Long addPointToUser(User user, MissionLog missionLog) {
// 비상 스위치가 꺼졌거나 미션을 받을 수 있는 앱이면 보상 경로 하나만 남긴다.
Expand Down
Loading
Loading