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
12 changes: 6 additions & 6 deletions .github/workflows/docker-be.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,16 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: "17"

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
uses: gradle/actions/setup-gradle@4c125117fe7c5aed11272ec4213f602f012f89f2 # v5

- name: Make gradlew executable
run: chmod +x gradlew
Expand All @@ -64,10 +64,10 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Login Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
Expand All @@ -84,7 +84,7 @@ jobs:
fi

- name: Build and Push
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
push: true
Expand Down
1 change: 1 addition & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ services:
AUTH_EMAIL_SUBJECT: ${AUTH_EMAIL_SUBJECT:-[GACHI] Email verification code}
AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET: ${AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET}
AUTH_RATE_LIMIT_TRUSTED_PROXIES: ${AUTH_RATE_LIMIT_TRUSTED_PROXIES:-127.0.0.1,::1}
MANAGEMENT_HEALTH_MAIL_ENABLED: ${MANAGEMENT_HEALTH_MAIL_ENABLED:-false}
CLOVA_OCR_API_URL: ${CLOVA_OCR_API_URL}
CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY}
PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID}
Expand Down
2 changes: 1 addition & 1 deletion docs/error-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

상세 에러 코드는 아래 Google Sheets를 단일 원본으로 관리합니다.

- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1519705696#gid=1519705696)
- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1155198711#gid=1155198711)

## 운영 원칙

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.gachi.be.domain.notification.api.controller;

import com.gachi.be.domain.notification.dto.request.NotificationReadRequest;
import com.gachi.be.domain.notification.dto.request.PushTokenDeleteRequest;
import com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest;
import com.gachi.be.domain.notification.dto.response.NotificationListResponse;
import com.gachi.be.domain.notification.dto.response.NotificationReadResponse;
import com.gachi.be.domain.notification.dto.response.NotificationUnreadCountResponse;
import com.gachi.be.domain.notification.dto.response.PushTokenResponse;
import com.gachi.be.domain.notification.service.NotificationService;
import com.gachi.be.global.api.ApiResponse;
import com.gachi.be.global.code.SuccessCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Notification", description = "알림 보관함 및 푸시 토큰 API")
@SecurityRequirement(name = "bearerAuth")
@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/api/v1/notifications")
public class NotificationController {

private final NotificationService notificationService;

/** 푸시 수신 누락을 복구하기 위해 서버에 저장된 알림 보관함을 최신순으로 조회한다. */
@Operation(
summary = "알림 목록 조회",
description =
"""
React Native 앱이 푸시를 받지 못한 경우에도 이 API로 서버 보관함을 동기화할 수 있습니다.
cursor는 이전 응답의 nextCursor를 그대로 전달하고, unreadOnly=true면 미읽음 알림만 조회합니다.
""")
@GetMapping
public ApiResponse<NotificationListResponse> getNotifications(
@AuthenticationPrincipal Long userId,
@Parameter(description = "다음 페이지 조회용 커서. 이전 응답의 nextCursor") @RequestParam(required = false)
Long cursor,
@Parameter(description = "조회 크기. 기본 20, 최대 100")
@RequestParam(defaultValue = "20")
@Min(1)
@Max(100)
Integer size,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
@Parameter(description = "미읽음 알림만 조회할지 여부") @RequestParam(defaultValue = "false")
boolean unreadOnly) {
return ApiResponse.success(
SuccessCode.NOTIFICATION_LIST_SUCCESS,
notificationService.getNotifications(userId, cursor, size, unreadOnly));
}

@Operation(summary = "미읽음 알림 수 조회", description = "사용자 기준 미읽음 알림 개수를 반환합니다.")
@GetMapping("/unread-count")
public ApiResponse<NotificationUnreadCountResponse> getUnreadCount(
@AuthenticationPrincipal Long userId) {
return ApiResponse.success(
SuccessCode.NOTIFICATION_UNREAD_COUNT_SUCCESS, notificationService.getUnreadCount(userId));
}

@Operation(summary = "단건 읽음 처리", description = "알림 상세 진입 또는 알림 탭 노출 후 단건 읽음 처리에 사용합니다.")
@PatchMapping("/{notificationId}/read")
public ApiResponse<NotificationReadResponse> markRead(
@AuthenticationPrincipal Long userId, @PathVariable Long notificationId) {
return ApiResponse.success(
SuccessCode.NOTIFICATION_READ_SUCCESS,
notificationService.markRead(userId, notificationId));
}

@Operation(summary = "일괄 읽음 처리", description = "앱이 서버 보관함을 동기화한 뒤 여러 알림을 한 번에 읽음 처리합니다.")
@PatchMapping("/read")
public ApiResponse<NotificationReadResponse> markRead(
@AuthenticationPrincipal Long userId, @Valid @RequestBody NotificationReadRequest request) {
return ApiResponse.success(
SuccessCode.NOTIFICATION_READ_SUCCESS, notificationService.markRead(userId, request));
}

@Operation(summary = "전체 읽음 처리", description = "현재 사용자의 미읽음 알림을 모두 읽음 처리합니다.")
@PatchMapping("/read-all")
public ApiResponse<NotificationReadResponse> markAllRead(@AuthenticationPrincipal Long userId) {
return ApiResponse.success(
SuccessCode.NOTIFICATION_READ_SUCCESS, notificationService.markAllRead(userId));
}

@Operation(
summary = "푸시 토큰 등록/갱신",
description =
"""
RN 앱 시작, 로그인 직후, 토큰 refresh 이벤트에서 호출합니다.
같은 토큰이 재등록되면 기존 레코드를 활성화하고 플랫폼/디바이스 정보를 갱신합니다.
""")
@PostMapping("/tokens")
public ApiResponse<PushTokenResponse> registerPushToken(
@AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenRegisterRequest request) {
return ApiResponse.success(
SuccessCode.NOTIFICATION_PUSH_TOKEN_REGISTERED,
notificationService.registerPushToken(userId, request));
}

@Operation(
summary = "푸시 토큰 삭제",
description = "로그아웃, 권한 철회, 앱 삭제 전 토큰 정리 시 호출합니다. 이미 삭제된 토큰도 성공으로 처리합니다.")
@DeleteMapping("/tokens")
public ApiResponse<Void> deletePushToken(
@AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenDeleteRequest request) {
notificationService.deletePushToken(userId, request);
return ApiResponse.success(SuccessCode.NOTIFICATION_PUSH_TOKEN_DELETED, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.gachi.be.domain.notification.dto.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

public record NotificationReadRequest(
@NotEmpty @Size(max = 100) List<@NotNull Long> notificationIds) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.gachi.be.domain.notification.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record PushTokenDeleteRequest(@NotBlank @Size(max = 512) String token) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.gachi.be.domain.notification.dto.request;

import com.gachi.be.domain.notification.entity.enums.PushPlatform;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record PushTokenRegisterRequest(
@NotNull PushPlatform platform,
@NotBlank @Size(max = 512) String token,
@Size(max = 128) String deviceId,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
@Size(max = 50) String appVersion) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.gachi.be.domain.notification.dto.response;

import java.util.List;

public record NotificationListResponse(
List<NotificationResponse> notifications, Long nextCursor, boolean hasNext) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.gachi.be.domain.notification.dto.response;

public record NotificationReadResponse(int readCount) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.gachi.be.domain.notification.dto.response;

import com.gachi.be.domain.notification.entity.enums.NotificationType;
import java.time.OffsetDateTime;
import java.util.Map;

public record NotificationResponse(
Long id,
NotificationType type,
String title,
String body,
Map<String, Object> payload,
boolean read,
OffsetDateTime readAt,
OffsetDateTime createdAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.gachi.be.domain.notification.dto.response;

public record NotificationUnreadCountResponse(long unreadCount) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.gachi.be.domain.notification.dto.response;

import com.gachi.be.domain.notification.entity.enums.PushPlatform;
import java.time.OffsetDateTime;

public record PushTokenResponse(
Long id,
PushPlatform platform,
String deviceId,
String appVersion,
boolean enabled,
OffsetDateTime lastRegisteredAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.gachi.be.domain.notification.entity;

import com.gachi.be.domain.notification.entity.enums.NotificationType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/** 푸시 수신 실패에도 앱이 다시 조회할 수 있는 사용자별 알림 보관함 엔티티. */
@Getter
@Entity
@Table(name = "notifications")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notification {

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

@Column(name = "user_id", nullable = false)
private Long userId;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 40)
private NotificationType type;

@Column(nullable = false, length = 120)
private String title;

@Column(nullable = false, columnDefinition = "TEXT")
private String body;

@Column(name = "payload_json", columnDefinition = "TEXT")
private String payloadJson;

@Column(name = "dedupe_key", length = 255)
private String dedupeKey;

@Column(name = "read_at")
private OffsetDateTime readAt;

@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;

@Builder
public Notification(
Long userId,
NotificationType type,
String title,
String body,
String payloadJson,
String dedupeKey) {
this.userId = userId;
this.type = type;
this.title = title;
this.body = body;
this.payloadJson = payloadJson;
this.dedupeKey = dedupeKey;
}

public boolean isRead() {
return readAt != null;
}

public void markRead() {
if (readAt == null) {
readAt = OffsetDateTime.now();
}
}

@PrePersist
protected void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
updatedAt = now;
}

@PreUpdate
protected void onUpdate() {
updatedAt = OffsetDateTime.now();
}
}
Loading
Loading