Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a3c9a37
#250 [Fix] 투표 비율 계산 기준을 preVoteOption에서 postVoteOption으로 수정 (#252)
HYH0804 May 27, 2026
a38c141
#254 [Refactor] 관점 등록/수정 시 GPT 검수 코드 제거 (#256)
HYH0804 Jun 3, 2026
cee2c5d
#255 [Refactor] VoteStatsResponse OptionStat에 title 필드 추가 (#257)
HYH0804 Jun 3, 2026
53533b8
#258 [Feat] 출석체크 API 추가
si-zero Jun 3, 2026
77db7a1
#260 [Feat] 스웨거 어드민/유저 분리
si-zero Jun 4, 2026
a9d4293
[Feat] 알림기능 (#263)
HYH0804 Jun 11, 2026
b5a681b
#262 [Fix] APNs 자격증명 S3 다운로드 시 임시파일 충돌 오류 수정
HYH0804 Jun 11, 2026
d281fa3
#264 [Fix] AdMob SSV 포인트 미적립 버그 수정
si-zero Jun 11, 2026
968d6a3
#262 merge (#266)
HYH0804 Jun 14, 2026
d041631
[Chore] adMob 크롤링 파일 설정: adMob 크롤링 파일 설정 #269
si-zero Jun 16, 2026
ec25882
#270 [Feat] 설정 파일 추가
si-zero Jun 16, 2026
4c45999
[Chore] JWT필터 설정 #271
si-zero Jun 16, 2026
e0c9c1c
#275 [Fix] AdMob SSV 콜백 파라미터 optional 처리 및 서명 검증 활성화
si-zero Jun 18, 2026
ab2cb78
#277 [Fix] AdMob SSV 콜백 URL 검증 요청 처리 실패
si-zero Jun 18, 2026
04d947c
#279 [Fix] AdMob SSV 엔드포인트 검증 실패 시 항상 200 반환
si-zero Jun 18, 2026
d9ea3b5
#283 [Fix] /me/content-activities voteSide null 반환 버그
si-zero Jun 19, 2026
ec27b01
#285 [Feat] content-activities 응답에 option_title 필드 추가
si-zero Jun 20, 2026
6663e7a
#287 [Fix] AdMob SSV 서명 검증 오류로 인한 포인트 미지급 버그
si-zero Jun 24, 2026
39aca84
#289 [Fix] 오늘의 배틀 개수 변경
si-zero Jul 5, 2026
11e2da1
#293 [Fix] 사전투표 철학자 아이콘 미노출
si-zero Jul 5, 2026
aa449f0
#297 [Fix] 댓글 작성 시각이 9시간 전으로 표시되는 버그
si-zero Jul 6, 2026
3259a1a
#301 [Fix] robots.txt 파일 부재로 AdMob app-ads.txt 크롤링 차단
si-zero Jul 6, 2026
e91819b
#305 [Fix] S3 오디오 조각 재다운로드 시 FileAlreadyExistsException으로 TTS 파이프라인 실패
si-zero Jul 6, 2026
d64d5ca
#309 [Fix] JwtFilter 자체 화이트리스트에 /robots.txt 누락으로 401 발생
si-zero Jul 6, 2026
ff16d85
#316 [Fix] 포인트 정책표 대비 코드 불일치 4건 수정
si-zero Jul 7, 2026
f50c749
Merge branch 'main' into dev
si-zero Jul 7, 2026
d9e28a1
#321 [Feat] 알림 미읽음 여부 확인 API 추가
si-zero Jul 7, 2026
4279341
#313 [Hotfix] Railway 배포 환경 FFmpeg 자동 설치 적용 (#320)
jucheonsu Jul 7, 2026
f77048c
#327 [Fix] 알림 전체 읽음 처리 후 모두 읽음 신호가 전달되지 않던 문제 수정
si-zero Jul 13, 2026
99886f9
#331 [Docs] 출석체크 API 명세서를 실제 코드에 맞춰 갱신
si-zero Jul 13, 2026
087b8c0
#333 [Fix] app-ads.txt가 text/html로 응답되어 AdMob 크롤러가 무시하는 문제 수정
si-zero Jul 13, 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
83 changes: 17 additions & 66 deletions docs/api-specs/attendance-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

- 출석체크 API는 `snake_case` 필드명을 기준으로 합니다.
- 출석 주기는 **월요일 기준 1주일(월~일)**로 초기화됩니다.
- 매일 1회 출석 체크 시 `+5P`를 지급하고, 7일 연속 출석 달성 시 추가로 `+7P`를 지급합니다.
- 7일 연속 출석 실패 시 다음 주 월요일에 연속 출석 카운트가 초기화됩니다.
- 매일 1회 출석 체크 시 `+5P`를 지급합니다. 단, 월~토 6일을 모두 출석한 뒤 맞이하는 일요일에는 `+5P` 대신 `+7P`를 **대체 지급**합니다(가산이 아님).
- 전날 출석 없이 출석 시 연속 출석 카운트가 1로 리셋됩니다.
- 인증이 필요한 모든 API는 `Authorization: Bearer {access_token}` 헤더를 요구합니다.
- 외부 응답에서는 내부 PK인 `user_id`를 노출하지 않고 `user_tag`를 사용합니다.
- 포인트 단위는 `P(포인트)`이며, 지급 내역은 별도 이력 테이블에 저장합니다.
- 포인트 단위는 `P(포인트)`이며, 지급 내역은 `credit_histories` 테이블에 저장됩니다. 출석체크 전용 이력 조회 API는 없고, 크레딧 적립/소비 통합 이력은 `GET /api/v1/me/credits/history`로 조회합니다.

### 1.1 출석 상태 정의

Expand All @@ -22,14 +22,15 @@

| 조건 | 지급 포인트 |
|------|-------------|
| 매일 출석 체크 | `+5P` |
| 7일 연속 출석 달성 (일요일) | 추가 `+7P` |
| 평일(월~토) 출석 체크 | `+5P` |
| 일요일 출석 체크 (월~토 개근 시) | `+5P` 대신 `+7P` **대체 지급** |
| 일요일 출석 체크 (월~토 중 결석 있음) | `+5P` (평일과 동일) |

### 1.3 연속 출석 초기화 정책
### 1.3 연속 출석(streak) 정책

- 연속 출석 카운트는 **매주 월요일 00:00**에 초기화됩니다.
- 한 주(월~일) 내에 출석하지 않은 날이 있어도, 해당 주 일요일까지는 연속 출석 달성이 가능합니다.
- 단, 전날 출석 없이 오늘 출석 시 연속 카운트는 1로 리셋됩니다.
- 연속 출석 카운트(`consecutive_days`)는 통계·화면 표시용이며, 포인트 지급 여부는 **요일(일요일 여부)과 그 주 월~토 개근 여부**로만 결정됩니다. streak 값 자체가 포인트에 가산되지 않습니다.
- 전날 출석 기록이 없는 상태에서 오늘 출석하면 연속 카운트는 1로 리셋됩니다.
- 주간 통계는 매주 월요일 기준으로 초기화됩니다.

---

Expand All @@ -40,8 +41,8 @@
오늘의 출석을 체크하고 포인트를 지급합니다.

- 하루에 1회만 출석 가능합니다. 중복 출석 시 `409`를 반환합니다.
- 출석 성공 시 `+5P`를 즉시 지급합니다.
- 7일 연속 출석 달성 시 `+5P`에 추가로 `+7P`를 지급합니다.
- 평일(월~토) 출석 성공 시 `+5P`를 즉시 지급합니다.
- 일요일 출석 시, 그 주 월~토를 모두 출석했다면 `+5P` 대신 `+7P`를 대체 지급합니다.

요청 헤더:

Expand All @@ -65,15 +66,15 @@
}
```

7일 연속 출석 달성 시 응답:
일요일에 월~토 개근 보너스가 대체 지급된 응답 (이 날은 `points_earned`가 5가 아니라 7로 대체되며, `streak_bonus_points`는 이 대체된 값과 동일합니다):

```json
{
"statusCode": 200,
"data": {
"user_tag": "pique-5010f67e",
"attended_at": "2025-07-13T09:00:00Z",
"points_earned": 5,
"points_earned": 7,
"streak_bonus_earned": true,
"streak_bonus_points": 7,
"consecutive_days": 7,
Expand Down Expand Up @@ -152,62 +153,12 @@

---

### 2.4 `GET /api/v1/attendance/points/history`

출석 포인트 지급 이력을 조회합니다.

- 최신순으로 반환하며, 페이지네이션을 지원합니다.

요청 헤더:

- `Authorization: Bearer {access_token}`

쿼리 파라미터:

| 파라미터 | 타입 | 필수 | 설명 |
|----------|------|------|------|
| `page` | `integer` | N | 페이지 번호 (기본값: `1`) |
| `size` | `integer` | N | 페이지 크기 (기본값: `20`, 최대: `50`) |

응답:

```json
{
"statusCode": 200,
"data": {
"total_count": 32,
"page": 1,
"size": 20,
"items": [
{
"rewarded_at": "2025-07-10T09:00:00Z",
"points": 5,
"reason": "DAILY_ATTENDANCE",
"consecutive_days": 4
},
{
"rewarded_at": "2025-07-06T08:30:00Z",
"points": 7,
"reason": "STREAK_BONUS",
"consecutive_days": 7
}
]
},
"error": null
}
```

가능한 `reason` 값:

- `DAILY_ATTENDANCE` — 일일 출석 보상 (`+5P`)
- `STREAK_BONUS` — 7일 연속 출석 달성 보상 (`+7P`)

---
> 출석 포인트만 별도로 필터링해 조회하는 API는 없습니다. 크레딧 적립/소비 통합 이력은 `GET /api/v1/me/credits/history`(마이페이지 도메인)로 조회하며, 출석 보상은 `credit_type: TODAY_CREDIT`(평일 5P) 또는 `ATTENDANCE_STREAK`(일요일 대체 7P)로 구분됩니다.

## 3. 어드민 API

- 어드민 전용 API는 `Authorization: Bearer {access_token}` 헤더와 함께 어드민 권한이 있는 계정만 호출 가능합니다.
- 어드민 권한이 없는 계정이 호출 시 `403`과 `FORBIDDEN`을 반환합니다.
- 어드민 권한이 없는 계정이 호출 시 `403`과 `AUTH_FORBIDDEN`을 반환합니다.

### 3.1 `GET /api/v1/admin/attendance/stats`

Expand Down Expand Up @@ -352,7 +303,7 @@
| `400` | `COMMON_INVALID_PARAMETER` | 요청 파라미터가 잘못되었습니다. |
| `401` | `AUTH_ACCESS_TOKEN_EXPIRED` | Access Token 만료 — Refresh 필요 |
| `401` | `AUTH_REFRESH_TOKEN_EXPIRED` | Refresh Token 만료 — 재로그인 필요 |
| `403` | `FORBIDDEN` | 어드민 권한이 없는 사용자 |
| `403` | `AUTH_FORBIDDEN` | 어드민 권한이 없는 사용자 |
| `403` | `USER_BANNED` | 영구 제재된 사용자 |
| `403` | `USER_SUSPENDED` | 일정 기간 이용 정지된 사용자 |
| `404` | `USER_NOT_FOUND` | 존재하지 않는 `user_tag` |
Expand Down
40 changes: 35 additions & 5 deletions docs/api-specs/notification-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,35 @@

---

### 3.2 `GET /api/v1/notifications/{notificationId}`
### 3.2 `GET /api/v1/notifications/unread`

알림함 벨 아이콘 배지 표시용으로, 미읽음 알림 존재 여부만 가볍게 확인합니다. 목록 조회와 달리 페이지네이션 없이 boolean 하나만 반환합니다.

요청 헤더:

- `Authorization: Bearer {access_token}`

쿼리 파라미터:

| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `category` | `string` | N | `ALL` \| `CONTENT` \| `NOTICE` \| `EVENT` (생략 시 전체 기준) |

성공 응답 `200 OK`:

```json
{
"statusCode": 200,
"data": {
"hasUnread": true
},
"error": null
}
```

---

### 3.3 `GET /api/v1/notifications/{notificationId}`

알림 상세를 조회합니다. (목록과 동일한 필드 + `readAt`)

Expand Down Expand Up @@ -202,7 +230,7 @@

---

### 3.3 `PATCH /api/v1/notifications/{notificationId}/read`
### 3.4 `PATCH /api/v1/notifications/{notificationId}/read`

알림 1건을 읽음 처리합니다.

Expand All @@ -222,20 +250,22 @@

---

### 3.4 `PATCH /api/v1/notifications/read-all`
### 3.5 `PATCH /api/v1/notifications/read-all`

알림함의 모든 알림을 읽음 처리합니다.

요청 헤더:

- `Authorization: Bearer {access_token}`

성공 응답 `200 OK`:
성공 응답 `200 OK`: 처리 직후 미읽음 알림 존재 여부(`hasUnread`)를 함께 내려줍니다. 모든 알림을 읽었다면 `hasUnread`는 `false`입니다.

```json
{
"statusCode": 200,
"data": null,
"data": {
"hasUnread": false
},
"error": null
}
```
Expand Down
281 changes: 0 additions & 281 deletions hs_err_pid172944.log

This file was deleted.

281 changes: 0 additions & 281 deletions hs_err_pid189152.log

This file was deleted.

281 changes: 0 additions & 281 deletions hs_err_pid192028.log

This file was deleted.

7 changes: 7 additions & 0 deletions railpack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://schema.railpack.com",
"buildAptPackages": ["ffmpeg"],
"deploy": {
"aptPackages": ["ffmpeg"]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
public class AttendanceService {

private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul");
private static final int STREAK_BONUS_THRESHOLD = 7;
private static final int STREAK_REWARD_POINTS = CreditType.ATTENDANCE_STREAK.getDefaultAmount();

private final AttendanceRecordRepository attendanceRecordRepository;
Expand All @@ -54,7 +53,11 @@ public CheckResponse check() {
throw new CustomException(ErrorCode.ATTENDANCE_ALREADY_CHECKED);
}

int dailyPoints = CreditType.TODAY_CREDIT.getDefaultAmount();
// 월~토 개근 후 맞는 일요일에는 5P 대신 7P를 지급(대체), 그 외에는 매일 5P
boolean perfectWeekBonus = isSundayWithFullWeekAttendance(user.getId(), today);
CreditType creditType = perfectWeekBonus ? CreditType.ATTENDANCE_STREAK : CreditType.TODAY_CREDIT;
int dailyPoints = creditType.getDefaultAmount();

AttendanceRecord record = AttendanceRecord.builder()
.user(user)
.attendedDate(today)
Expand All @@ -63,19 +66,19 @@ public CheckResponse check() {
attendanceRecordRepository.save(record);

Long referenceId = toDateLong(today);
creditService.addCredit(user.getId(), CreditType.TODAY_CREDIT, referenceId);
creditService.addCredit(user.getId(), creditType, referenceId);

AttendanceStreak streak = getOrCreateStreak(user, today);
boolean streakBonusEarned = updateStreak(streak, today, user.getId());
updateStreak(streak, today);

int streakBonusPoints = streakBonusEarned ? STREAK_REWARD_POINTS : 0;
int streakBonusPoints = perfectWeekBonus ? dailyPoints : 0;
int totalPoints = creditService.getTotalPoints(user.getId());

return new CheckResponse(
user.getUserTag(),
LocalDateTime.now(SEOUL_ZONE),
dailyPoints,
streakBonusEarned,
perfectWeekBonus,
streakBonusPoints,
streak.getCurrentStreak(),
totalPoints
Expand Down Expand Up @@ -174,28 +177,28 @@ private AttendanceStreak getOrCreateStreak(User user, LocalDate today) {
});
}

private boolean updateStreak(AttendanceStreak streak, LocalDate today, Long userId) {
private void updateStreak(AttendanceStreak streak, LocalDate today) {
LocalDate yesterday = today.minusDays(1);
boolean continuedStreak = attendanceRecordRepository
.existsByUserIdAndAttendedDate(userId, yesterday);
.existsByUserIdAndAttendedDate(streak.getUserId(), yesterday);

if (continuedStreak || streak.getCurrentStreak() == 0) {
streak.attend(today);
} else {
LocalDate weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
streak.resetStreak(weekStart);
}
}

boolean justAchieved = streak.getCurrentStreak() >= STREAK_BONUS_THRESHOLD
&& streak.isStreakAchieved()
&& !creditService.existsHistory(userId, CreditType.ATTENDANCE_STREAK, toDateLong(streak.getStreakWeekStart()) + 7L);

if (justAchieved) {
Long streakReferenceId = toDateLong(streak.getStreakWeekStart()) + 7L;
creditService.addCredit(userId, CreditType.ATTENDANCE_STREAK, streakReferenceId);
private boolean isSundayWithFullWeekAttendance(Long userId, LocalDate today) {
if (today.getDayOfWeek() != DayOfWeek.SUNDAY) {
return false;
}

return justAchieved;
LocalDate monday = today.minusDays(6);
LocalDate saturday = today.minusDays(1);
List<AttendanceRecord> monToSatRecords =
attendanceRecordRepository.findByUserIdAndAttendedDateBetween(userId, monday, saturday);
return monToSatRecords.size() == 6;
}

private Long toDateLong(LocalDate date) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse;
import com.swyp.picke.domain.notification.dto.response.NotificationListResponse;
import com.swyp.picke.domain.notification.dto.response.NotificationUnreadResponse;
import com.swyp.picke.domain.notification.enums.NotificationCategory;
import com.swyp.picke.domain.notification.service.NotificationService;
import com.swyp.picke.global.common.response.ApiResponse;
Expand Down Expand Up @@ -35,6 +36,15 @@ public ApiResponse<NotificationListResponse> getNotifications(
return ApiResponse.onSuccess(notificationService.getNotifications(userId, category, page, size));
}

@Operation(summary = "알림 미읽음 여부 확인 (배지용)")
@GetMapping("/unread")
public ApiResponse<NotificationUnreadResponse> hasUnread(
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) NotificationCategory category
) {
return ApiResponse.onSuccess(notificationService.hasUnread(userId, category));
}

@Operation(summary = "알림 상세 조회")
@GetMapping("/{notificationId}")
public ApiResponse<NotificationDetailResponse> getNotificationDetail(
Expand All @@ -56,8 +66,8 @@ public ApiResponse<Void> markAsRead(

@Operation(summary = "알림 전체 읽음 처리")
@PatchMapping("/read-all")
public ApiResponse<Void> markAllAsRead(@AuthenticationPrincipal Long userId) {
public ApiResponse<NotificationUnreadResponse> markAllAsRead(@AuthenticationPrincipal Long userId) {
notificationService.markAllAsRead(userId);
return ApiResponse.onSuccess(null);
return ApiResponse.onSuccess(notificationService.hasUnread(userId, NotificationCategory.ALL));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.swyp.picke.domain.notification.dto.response;

public record NotificationUnreadResponse(
boolean hasUnread
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ AND NOT EXISTS (
""")
boolean hasUnreadBroadcast(@Param("userId") Long userId, @Param("category") NotificationCategory category);

@Query("""
SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END
FROM Notification n
WHERE (:category IS NULL OR n.category = :category)
AND (
(n.user.id = :userId AND n.read = false)
OR (n.user IS NULL AND NOT EXISTS (
SELECT 1 FROM NotificationRead nr
WHERE nr.notification = n AND nr.userId = :userId
))
)
""")
boolean existsUnread(@Param("userId") Long userId, @Param("category") NotificationCategory category);

@Modifying
@Query("""
UPDATE Notification n SET n.read = true, n.readAt = CURRENT_TIMESTAMP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse;
import com.swyp.picke.domain.notification.dto.response.NotificationListResponse;
import com.swyp.picke.domain.notification.dto.response.NotificationSummaryResponse;
import com.swyp.picke.domain.notification.dto.response.NotificationUnreadResponse;
import com.swyp.picke.domain.notification.entity.Notification;
import com.swyp.picke.domain.notification.entity.NotificationRead;
import com.swyp.picke.domain.notification.enums.NotificationCategory;
Expand Down Expand Up @@ -146,6 +147,11 @@ public boolean hasNewBroadcast(Long userId, NotificationCategory category) {
return notificationRepository.hasUnreadBroadcast(userId, category);
}

public NotificationUnreadResponse hasUnread(Long userId, NotificationCategory category) {
NotificationCategory filterCategory = (category == null || category == NotificationCategory.ALL) ? null : category;
return new NotificationUnreadResponse(notificationRepository.existsUnread(userId, filterCategory));
}

private Notification getAccessibleNotification(Long userId, Long notificationId) {
Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ public String processReward(AdMobRewardRequest request) {
log.info("[AdMob] 보상 이력 저장 완료: historyId={}", history.getId());

// 6. 크레딧 적립 (history.getId()를 referenceId로 사용해 unique 충돌 방지)
creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, request.reward_amount(), history.getId());
// AdMob이 보낸 reward_amount는 콘솔 설정에 따라 달라질 수 있어 신뢰하지 않고, 정책상 고정값(FREE_CHARGE)만 지급한다
creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, CreditType.FREE_CHARGE.getDefaultAmount(), history.getId());
log.info("[AdMob] 포인트 적립 완료: userId={}, amount={}, historyId={}",
user.getId(), request.reward_amount(), history.getId());
user.getId(), CreditType.FREE_CHARGE.getDefaultAmount(), history.getId());

return "OK";
}
Expand Down
Loading
Loading