Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a64963a
feat: UserAttendance 및 관련 코드 추가
hjbin-25 Apr 13, 2026
77fbcff
feat: 유저 출석 일괄 생성 로직 추가
hjbin-25 Apr 13, 2026
8935e11
feat: currentAttendanceStreak 추가 및 연관 로직 수정
hjbin-25 Apr 13, 2026
6b586fb
feat: 출석 체크 및 연속 출석 집계 기능 추가
hjbin-25 Apr 14, 2026
250b848
test: 출석 부분 단위 테스트 추가
hjbin-25 Apr 14, 2026
a00d527
feat: 연속 출석 중단 스케줄러 추가
hjbin-25 Apr 14, 2026
d3d22d3
refactor: Modifying 쿼리 대신 단순 반복문 사용으로 불필요한 쓰기 트랜젝션 제거
hjbin-25 Apr 14, 2026
04614f2
test: 연속 출석 종료 관련 단위 테스트 추가
hjbin-25 Apr 14, 2026
1b287fc
feat: 쿠키 지급 NoticeCategory 추가
hjbin-25 Apr 14, 2026
a13ae49
feat: 출석에 따른 쿠키 지급 추가
hjbin-25 Apr 14, 2026
23ac85c
feat: 주간 출석 조회 api 추가
hjbin-25 Apr 14, 2026
3ecc4bf
refactor: UserAttendanceController로 API 분리
hjbin-25 Apr 14, 2026
ac70d58
feat: 최근 주차 출석 조회 로직서 N주차 정보를 추가로 반환
hjbin-25 Apr 14, 2026
1eaaf2b
feat: 해당 주 출석 조회 로직에 소켓 연동
hjbin-25 Apr 14, 2026
0262fa3
fix: 7일 연속 출석시 1000쿠키를 지급하도록 수정
hjbin-25 Apr 14, 2026
534978b
feat: 해당주 출석 정보에서 유저의 총 연속 출석일을 추가로 반환
hjbin-25 Apr 14, 2026
dd550af
fix: broadcastAttendanceBoard 스케줄러 시간을 07:00-05:00로 조정
hjbin-25 Apr 14, 2026
57d54ea
docs: 유저 출석 스웨거 업데이트
hjbin-25 Apr 14, 2026
3703606
fix: flyway 스크립트 버전 수정
hjbin-25 Apr 14, 2026
4a28e58
fix: 06시 이후 가입한 신규 유저 출석 시 레코드 자동 생성
hjbin-25 Apr 14, 2026
7d367e7
test: 불필요한 테스트 제거
hjbin-25 Apr 14, 2026
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 @@ -82,4 +82,7 @@ public class UserJpaEntity {
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ExpTier currentExpTier;

@Column(nullable = false)
private int currentAttendanceStreak;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public UserJpaEntity toJpaEntity(User user) {
user.deletedAt(),
user.isPrivate(),
user.currentRankTier(),
user.currentExpTier()
user.currentExpTier(),
user.currentAttendanceStreak()
);
}

Expand All @@ -46,7 +47,8 @@ public User toDomain(UserJpaEntity userJpaEntity) {
userJpaEntity.getDeletedAt(),
userJpaEntity.isPrivate(),
userJpaEntity.getCurrentRankTier(),
userJpaEntity.getCurrentExpTier()
userJpaEntity.getCurrentExpTier(),
userJpaEntity.getCurrentAttendanceStreak()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.process.clash.adapter.persistence.user.userattendance;

import com.process.clash.adapter.persistence.user.user.UserJpaEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.Instant;
import java.time.LocalDate;

@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(
name = "user_attendances",
uniqueConstraints = @UniqueConstraint(columnNames = {"fk_user_id", "attendance_date"})
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class UserAttendanceJpaEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@CreatedDate
@Column(nullable = false)
private Instant createdAt;

@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "fk_user_id", nullable = false)
private UserJpaEntity user;

@Column(nullable = false)
private LocalDate attendanceDate;

@Column(nullable = false)
private boolean isAttended;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.process.clash.adapter.persistence.user.userattendance;

import com.process.clash.adapter.persistence.user.user.UserJpaEntity;
import com.process.clash.domain.user.userattendance.entity.UserAttendance;
import org.springframework.stereotype.Component;

@Component
public class UserAttendanceJpaMapper {

public UserAttendanceJpaEntity toJpaEntity(UserAttendance userAttendance, UserJpaEntity userJpaEntity) {
return new UserAttendanceJpaEntity(
userAttendance.id(),
userAttendance.createdAt(),
userAttendance.updatedAt(),
userJpaEntity,
userAttendance.attendanceDate(),
userAttendance.isAttended()
);
}

public UserAttendance toDomain(UserAttendanceJpaEntity entity) {
return new UserAttendance(
entity.getId(),
entity.getCreatedAt(),
entity.getUpdatedAt(),
entity.getUser().getId(),
entity.getAttendanceDate(),
entity.isAttended()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.process.clash.adapter.persistence.user.userattendance;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

@Repository
public interface UserAttendanceJpaRepository extends JpaRepository<UserAttendanceJpaEntity, Long> {

@Query("SELECT ua FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate = :attendanceDate")
Optional<UserAttendanceJpaEntity> findByUserIdAndAttendanceDate(
@Param("userId") Long userId,
@Param("attendanceDate") LocalDate attendanceDate
);

@Query(value = """
SELECT u.id
FROM users u
WHERE u.deleted_at IS NULL
""", nativeQuery = true)
List<Long> findAllNonDeletedUserIds();

@Query("SELECT ua.user.id FROM UserAttendanceJpaEntity ua WHERE ua.attendanceDate = :date AND ua.isAttended = false")
List<Long> findNotAttendedUserIdsByDate(@Param("date") LocalDate date);

@Query("SELECT COUNT(ua) FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate BETWEEN :start AND :end AND ua.isAttended = true")
long countAttendedByUserIdBetween(@Param("userId") Long userId, @Param("start") LocalDate start, @Param("end") LocalDate end);

@Query("SELECT ua FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate BETWEEN :start AND :end")
List<UserAttendanceJpaEntity> findByUserIdBetweenDates(@Param("userId") Long userId, @Param("start") LocalDate start, @Param("end") LocalDate end);

@Modifying
@Query(value = """
INSERT INTO user_attendances (fk_user_id, attendance_date, is_attended, created_at, updated_at)
SELECT id, :date, false, NOW(), NOW()
FROM users
WHERE deleted_at IS NULL
ON CONFLICT (fk_user_id, attendance_date) DO NOTHING
""", nativeQuery = true)
void initDailyAttendanceForAllUsers(@Param("date") LocalDate date);

@Query("SELECT MIN(ua.attendanceDate) FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId")
Optional<LocalDate> findEarliestAttendanceDateByUserId(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.process.clash.adapter.persistence.user.userattendance;

import com.process.clash.adapter.persistence.user.user.UserJpaEntity;
import com.process.clash.adapter.persistence.user.user.UserJpaRepository;
import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort;
import com.process.clash.domain.user.userattendance.entity.UserAttendance;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class UserAttendancePersistenceAdapter implements UserAttendanceRepositoryPort {

private final UserAttendanceJpaRepository userAttendanceJpaRepository;
private final UserAttendanceJpaMapper userAttendanceJpaMapper;
private final UserJpaRepository userJpaRepository;

@Override
public UserAttendance save(UserAttendance userAttendance) {
UserJpaEntity userJpaEntity = userJpaRepository.getReferenceById(userAttendance.userId());
UserAttendanceJpaEntity savedEntity = userAttendanceJpaRepository.save(
userAttendanceJpaMapper.toJpaEntity(userAttendance, userJpaEntity)
);
return userAttendanceJpaMapper.toDomain(savedEntity);
}

@Override
public Optional<UserAttendance> findByUserIdAndAttendanceDate(Long userId, LocalDate attendanceDate) {
return userAttendanceJpaRepository.findByUserIdAndAttendanceDate(userId, attendanceDate)
.map(userAttendanceJpaMapper::toDomain);
}

@Override
public List<Long> findAllNonDeletedUserIds() {
return userAttendanceJpaRepository.findAllNonDeletedUserIds();
}

@Override
public void saveAll(List<UserAttendance> attendances) {
List<UserAttendanceJpaEntity> entities = attendances.stream()
.map(attendance -> userAttendanceJpaMapper.toJpaEntity(
attendance, userJpaRepository.getReferenceById(attendance.userId())
))
.toList();
userAttendanceJpaRepository.saveAll(entities);
}

@Override
public void deleteAll() {
userAttendanceJpaRepository.deleteAllInBatch();
}

@Override
public List<Long> findNotAttendedUserIdsByDate(LocalDate date) {
return userAttendanceJpaRepository.findNotAttendedUserIdsByDate(date);
}

@Override
public long countAttendedByUserIdBetween(Long userId, LocalDate start, LocalDate end) {
return userAttendanceJpaRepository.countAttendedByUserIdBetween(userId, start, end);
}

@Override
public List<UserAttendance> findByUserIdBetweenDates(Long userId, LocalDate start, LocalDate end) {
return userAttendanceJpaRepository.findByUserIdBetweenDates(userId, start, end)
.stream()
.map(userAttendanceJpaMapper::toDomain)
.toList();
}

@Override
public void initDailyAttendanceForAllUsers(LocalDate date) {
userAttendanceJpaRepository.initDailyAttendanceForAllUsers(date);
}

@Override
public Optional<LocalDate> findEarliestAttendanceDateByUserId(Long userId) {
return userAttendanceJpaRepository.findEarliestAttendanceDateByUserId(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.process.clash.adapter.scheduler;

import com.process.clash.application.user.userattendance.service.BroadcastAttendanceBoardService;
import com.process.clash.application.user.userattendance.service.InitUserAttendanceService;
import com.process.clash.application.user.userattendance.service.ResetAttendanceStreakService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class UserAttendanceInitScheduler {

private final InitUserAttendanceService initUserAttendanceService;
private final ResetAttendanceStreakService resetAttendanceStreakService;
private final BroadcastAttendanceBoardService broadcastAttendanceBoardService;

/**
* 매일 KST 06:00에 다음 두 작업을 순서대로 수행한다.
* 1. 전날 출석하지 않은 유저들의 streak 초기화 + 소켓 알림 발송
* 2. 전체 활성 유저의 오늘 출석 레코드 생성 (이미 존재하면 무시)
*/
@Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul")
public void initDailyAttendance() {
try {
log.info("연속 출석 streak 초기화 스케줄 실행 시작");
resetAttendanceStreakService.resetStreakForMissedAttendance();
log.info("연속 출석 streak 초기화 스케줄 실행 완료");
} catch (Exception e) {
log.error("연속 출석 streak 초기화 스케줄 실행 실패", e);
}

try {
log.info("출석 초기화 스케줄 실행 시작");
initUserAttendanceService.initDailyAttendance();
log.info("출석 초기화 스케줄 실행 완료");
} catch (Exception e) {
log.error("출석 초기화 스케줄 실행 실패", e);
}
}

/**
* 매일 KST 07:00 ~ 05:00 매 정시마다 전체 활성 유저에게 주간 출석판 갱신 알림을 전송한다.
* 06:00 이후에 앱을 실행한 유저도 최대 1시간 이내에 출석판을 확인할 수 있도록 한다.
*/
@Scheduled(cron = "0 0 0-5,7-23 * * *", zone = "Asia/Seoul")
public void broadcastAttendanceBoard() {
try {
log.info("출석판 브로드캐스트 스케줄 실행 시작");
broadcastAttendanceBoardService.broadcastAttendanceBoard();
log.info("출석판 브로드캐스트 스케줄 실행 완료");
} catch (Exception e) {
log.error("출석판 브로드캐스트 스케줄 실행 실패", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.process.clash.adapter.web.user.controller;

import com.process.clash.adapter.web.common.ApiResponse;
import com.process.clash.adapter.web.security.AuthenticatedActor;
import com.process.clash.adapter.web.user.docs.controller.UserAttendanceControllerDocument;
import com.process.clash.adapter.web.user.dto.GetWeeklyAttendanceDto;
import com.process.clash.adapter.web.user.dto.MarkAttendanceDto;
import com.process.clash.application.common.actor.Actor;
import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData;
import com.process.clash.application.user.userattendance.data.MarkAttendanceData;
import com.process.clash.application.user.userattendance.port.in.GetWeeklyAttendanceUseCase;
import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users/me/attendance")
@RequiredArgsConstructor
public class UserAttendanceController implements UserAttendanceControllerDocument {

private final MarkAttendanceUseCase markAttendanceUseCase;
private final GetWeeklyAttendanceUseCase getWeeklyAttendanceUseCase;

@PostMapping
public ApiResponse<MarkAttendanceDto.Response> markAttendance(
@AuthenticatedActor Actor actor
) {
MarkAttendanceData.Command command = new MarkAttendanceData.Command(actor);
MarkAttendanceData.Result result = markAttendanceUseCase.execute(command);
MarkAttendanceDto.Response response = MarkAttendanceDto.Response.from(result);
return ApiResponse.success(response, "출석이 완료되었습니다.");
}

@GetMapping("/weekly")
public ApiResponse<GetWeeklyAttendanceDto.Response> getWeeklyAttendance(
@AuthenticatedActor Actor actor
) {
GetWeeklyAttendanceData.Command command = new GetWeeklyAttendanceData.Command(actor);
GetWeeklyAttendanceData.Result result = getWeeklyAttendanceUseCase.execute(command);
GetWeeklyAttendanceDto.Response response = GetWeeklyAttendanceDto.Response.from(result);
return ApiResponse.success(response, "주간 출석 현황을 성공적으로 조회했습니다.");
}
}
Loading
Loading