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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,11 @@ AUTH_RATE_LIMIT_KEY_PREFIX=auth:rate-limit:
AUTH_RATE_LIMIT_LOGIN_LIMIT=5
AUTH_RATE_LIMIT_LOGIN_WINDOW_SECONDS=60
AUTH_RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1,::1

# Push notification
NOTIFICATION_PUSH_ENABLED=false
NOTIFICATION_PUSH_PROVIDER=expo
NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS=5
NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS=10
EXPO_PUSH_API_URL=https://exp.host/--/api/v2/push/send
EXPO_PUSH_ACCESS_TOKEN=
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
7 changes: 7 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,12 @@ AWS_S3_BUCKET=
AWS_S3_PUBLIC_BASE_URL=
AWS_S3_IMAGE_PREFIX=images

NOTIFICATION_PUSH_ENABLED=false
NOTIFICATION_PUSH_PROVIDER=expo
NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS=5
NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS=10
EXPO_PUSH_API_URL=https://exp.host/--/api/v2/push/send
EXPO_PUSH_ACCESS_TOKEN=

# Let's Encrypt account email (required for production operations)
CERTBOT_EMAIL=admin@example.com
7 changes: 7 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ 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}
NOTIFICATION_PUSH_ENABLED: ${NOTIFICATION_PUSH_ENABLED:-false}
NOTIFICATION_PUSH_PROVIDER: ${NOTIFICATION_PUSH_PROVIDER:-expo}
NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS: ${NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS:-5}
NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS: ${NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS:-10}
EXPO_PUSH_API_URL: ${EXPO_PUSH_API_URL:-https://exp.host/--/api/v2/push/send}
EXPO_PUSH_ACCESS_TOKEN: ${EXPO_PUSH_ACCESS_TOKEN:-}
CLOVA_OCR_API_URL: ${CLOVA_OCR_API_URL}
CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY}
PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID}
Expand Down
9 changes: 9 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@
- `AUTH_RATE_LIMIT_LOGIN_LIMIT`: `/api/v1/auth/login` 윈도우 내 허용 횟수 (기본값: `5`)
- `AUTH_RATE_LIMIT_LOGIN_WINDOW_SECONDS`: `/api/v1/auth/login` 윈도우 길이(초) (기본값: `60`)

## Push Notification

- `NOTIFICATION_PUSH_ENABLED`: 실제 외부 푸시 발송 여부. 기본값은 `false`이며, 서버 보관함 알림 생성은 이 값과 무관하게 유지됩니다.
- `NOTIFICATION_PUSH_PROVIDER`: 푸시 발송 provider. 현재 지원값은 `expo`입니다.
- `NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS`: Expo Push API 연결 타임아웃(초)
- `NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS`: Expo Push API 응답 타임아웃(초)
- `EXPO_PUSH_API_URL`: Expo Push API 발송 엔드포인트. 기본값은 `https://exp.host/--/api/v2/push/send`
- `EXPO_PUSH_ACCESS_TOKEN`: Expo push security가 활성화된 프로젝트에서 사용하는 access token. 비어 있으면 Authorization 헤더를 보내지 않습니다.

## AWS Credential

- Local/dev: AWS CLI profile 또는 환경변수 자격증명 사용
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,
@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,
@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) {}
Loading
Loading