Skip to content
Open
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,10 @@ src/main/resources/application-prod.yml

### Firebase ###
src/main/resources/firebase/

### macOS ###
.DS_Store
**/.DS_Store

### Node ###
node_modules/
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@

import com.assu.server.domain.backoffice.annotation.BackofficeAudited;
import com.assu.server.domain.inquiry.dto.InquiryAnswerRequestDTO;
import com.assu.server.domain.inquiry.dto.InquiryResponseDTO;
import com.assu.server.domain.inquiry.service.InquiryService;
import com.assu.server.global.apiPayload.BaseResponse;
import com.assu.server.global.apiPayload.code.status.SuccessStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import com.assu.server.domain.common.dto.PageResponseDTO;
import com.assu.server.domain.inquiry.entity.Inquiry;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Backoffice", description = "백오피스 운영 API")
Expand All @@ -25,6 +31,56 @@ public class BackofficeInquiryController {

private final InquiryService inquiryService;

@Operation(
summary = "운영자 전체 문의 목록 조회 API",
description = "# [v1.1 (2026-07-04)]\n" +
"- 모든 사용자의 문의를 페이징으로 조회합니다.\n" +
"- `status` 파라미터로 상태 필터링 가능합니다 (ALL / WAITING / ANSWERED).\n" +
"- `keyword`가 있으면 제목 또는 본문에서 검색합니다.\n\n" +
"**Query Parameters:**\n" +
"- `status` (default: ALL): 문의 상태 필터\n" +
"- `keyword` (optional): 제목 또는 본문 검색어\n" +
"- `page` (default: 1): 페이지 번호\n" +
"- `size` (default: 20): 페이지 크기\n\n" +
"**Response:**\n" +
"- 200(OK): 문의 목록 페이지 반환\n" +
"- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" +
"- 403(FORBIDDEN): BACKOFFICE 권한 없음"
)
@GetMapping
public BaseResponse<PageResponseDTO<InquiryResponseDTO>> listAllInquiries(
@Parameter(description = "상태 필터 (ALL / WAITING / ANSWERED)", example = "ALL")
@RequestParam(defaultValue = "ALL") Inquiry.StatusFilter status,
@Parameter(description = "제목 또는 본문 검색어")
@RequestParam(required = false) String keyword,
@Parameter(description = "페이지 번호 (1 이상)", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "페이지 크기 (1~200)", example = "20")
@RequestParam(defaultValue = "20") int size
Comment on lines +52 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestParam이 많아질 경우 ModelAttribute를 고려해봐도 좋을 것 같아요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

) {
return BaseResponse.onSuccess(SuccessStatus._OK, inquiryService.getAllInquiries(status, keyword, page, size));
}

@Operation(
summary = "운영자 문의 상세 조회 API",
description = "# [v1.0 (2026-06-25)]()\n" +
"- 특정 문의의 상세 내용을 조회합니다.\n" +
"- 소유권 검증 없이 모든 문의에 접근 가능합니다.\n\n" +
"**Path Variable:**\n" +
"- `inquiryId` (Long, required): 문의 ID\n\n" +
"**Response:**\n" +
"- 200(OK): 문의 상세 반환\n" +
"- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" +
"- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" +
"- 404(NOT_FOUND): 존재하지 않는 문의 ID"
)
@GetMapping("/{inquiryId}")
public BaseResponse<InquiryResponseDTO> getInquiry(
@PathVariable("inquiryId") Long inquiryId
) {
return BaseResponse.onSuccess(SuccessStatus._OK, inquiryService.getById(inquiryId));
}

@BackofficeAudited(action = "INQUIRY_ANSWER", targetId = "#inquiryId")
@Operation(
summary = "운영자 문의 답변 API",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.assu.server.domain.backoffice.controller;

import com.assu.server.domain.backoffice.annotation.BackofficeAudited;
import com.assu.server.domain.backoffice.dto.BackofficeOutboxResponseDTO;
import com.assu.server.domain.backoffice.dto.BackofficePushLogResponseDTO;
import com.assu.server.domain.backoffice.dto.BackofficePushSendRequestDTO;
import com.assu.server.domain.backoffice.service.BackofficeNotificationService;
import com.assu.server.domain.common.dto.PageResponseDTO;
import com.assu.server.global.apiPayload.BaseResponse;
import com.assu.server.global.apiPayload.code.status.SuccessStatus;
import com.assu.server.global.util.PrincipalDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Backoffice", description = "백오피스 운영 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/backoffice/notifications")
@PreAuthorize("hasRole('BACKOFFICE')")
public class BackofficeNotificationController {

private final BackofficeNotificationService backofficeNotificationService;

@BackofficeAudited(action = "PUSH_SEND", targetId = "#request.receiverId()")
@Operation(
summary = "운영자 수동 푸시 알림 전송 API",
description = "# [v2.0 (2026-07-04)]\n" +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버전은 소수점 단위만 우선 올려주는 것으로 해요. 정수 단위는 전체 버전을 표현할 때 사용하기 위함이라고 생각합니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

흠 알겠습니다 다른 api들 버전도 체크해 봐야겠네요

"- 그룹 또는 특정 사용자에게 자유 메시지 푸시 알림을 전송합니다.\n" +
"- `targetType`에 따라 수신자 범위가 결정됩니다.\n\n" +
"**Request Body:**\n" +
"- `targetType` (required): ALL / STUDENT / UNION / PARTNER / INDIVIDUAL\n" +
"- `receiverId` (INDIVIDUAL일 때만 필수): 수신자 멤버 ID\n" +
"- `title` (required): 푸시 제목\n" +
"- `body` (required): 푸시 본문\n" +
"- `deepLink` (optional): 딥링크 URL\n\n" +
"**Response:**\n" +
"- 200(OK): 발송 완료\n" +
"- 400(BAD_REQUEST): 필수 파라미터 누락\n" +
"- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" +
"- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" +
"- 404(NOT_FOUND): 존재하지 않는 수신자 멤버 ID"
)
@PostMapping("/push")
public BaseResponse<String> sendPush(
@RequestBody @Valid BackofficePushSendRequestDTO request,
@AuthenticationPrincipal PrincipalDetails principal
) {
backofficeNotificationService.sendPush(request, principal.getMemberId());
return BaseResponse.onSuccess(SuccessStatus._OK, "Push notifications sent successfully. targetType=" + request.targetType());
}

@Operation(
summary = "푸시 발송 이력 조회 API",
description = "# [v2.0 (2026-07-04)]\n" +
"- 백오피스에서 발송한 푸시 알림 이력을 페이징으로 조회합니다.\n" +
"- `keyword`가 있으면 제목/본문에서 검색합니다.\n\n" +
"**Query Parameters:**\n" +
"- `keyword` (optional): 제목 또는 본문 검색어\n" +
"- `page` (default: 1): 페이지 번호\n" +
"- `size` (default: 20): 페이지 크기\n\n" +
"**Response:**\n" +
"- 200(OK): 푸시 이력 목록 페이지 반환\n" +
"- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" +
"- 403(FORBIDDEN): BACKOFFICE 권한 없음"
)
@GetMapping
public BaseResponse<PageResponseDTO<BackofficePushLogResponseDTO>> getPushLogs(
@Parameter(description = "제목 또는 본문 검색어")
@RequestParam(required = false) String keyword,
@Parameter(description = "페이지 번호 (1 이상)", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "페이지 크기 (1~200)", example = "20")
@RequestParam(defaultValue = "20") int size
) {
return BaseResponse.onSuccess(SuccessStatus._OK, backofficeNotificationService.getPushLogs(keyword, page, size));
}

@Operation(
summary = "전송 실패 알림 목록 조회 API",
description = "# [v1.0 (2026-06-25)]\n" +
"- 전송 상태가 FAILED인 알림 Outbox 목록을 페이징으로 조회합니다.\n" +
"- 자동 재시도 한도(3회)를 초과하여 실패 처리된 알림을 확인할 때 사용합니다.\n\n" +
"**Query Parameters:**\n" +
"- `page` (default: 1): 페이지 번호\n" +
"- `size` (default: 20): 페이지 크기\n\n" +
"**Response:**\n" +
"- 200(OK): 실패 알림 목록 페이지 반환\n" +
"- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" +
"- 403(FORBIDDEN): BACKOFFICE 권한 없음"
)
@GetMapping("/outbox/failed")
public BaseResponse<PageResponseDTO<BackofficeOutboxResponseDTO>> getFailedOutboxes(
@Parameter(description = "페이지 번호 (1 이상)", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "페이지 크기 (1~200)", example = "20")
@RequestParam(defaultValue = "20") int size
) {
return BaseResponse.onSuccess(SuccessStatus._OK, backofficeNotificationService.getFailedOutboxes(page, size));
}

@BackofficeAudited(action = "PUSH_RETRY", targetId = "#outboxId")
@Operation(
summary = "실패 알림 수동 재전송 API",
description = "# [v1.0 (2026-06-25)]\n" +
"- FAILED 상태의 특정 알림 Outbox를 수동으로 재전송합니다.\n" +
"- 자동 재시도 횟수(retryCount)와 무관하게 재전송 가능합니다.\n" +
"- 재전송 시 Outbox 상태가 PENDING으로 초기화되고 전송 파이프라인에 재등록됩니다.\n\n" +
"**Path Variable:**\n" +
"- `outboxId` (Long, required): 재전송할 Outbox ID\n\n" +
"**Response:**\n" +
"- 200(OK): 재전송 큐 등록 성공\n" +
"- 400(BAD_REQUEST): FAILED 상태가 아닌 Outbox\n" +
"- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" +
"- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" +
"- 404(NOT_FOUND): 존재하지 않는 Outbox ID"
)
@PostMapping("/outbox/{outboxId}/retry")
public BaseResponse<String> retryOutbox(
@PathVariable("outboxId") Long outboxId
) {
backofficeNotificationService.retryOutbox(outboxId);
return BaseResponse.onSuccess(SuccessStatus._OK, "Outbox retry queued successfully. outboxId=" + outboxId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backofficeNotificationService.retryOutbox(outboxId);의 응답을 onSuccess() 메소드에 담아 DTO로 변환하는 것으로 해요. 가독성과 유지보수를 위해 ID값을 String과 함께 조합해서 반환하는 것을 지양하는 것이 좋아보입니다.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.assu.server.domain.backoffice.dto;

import com.assu.server.domain.notification.entity.Notification;
import com.assu.server.domain.notification.entity.NotificationOutbox;
import io.swagger.v3.oas.annotations.media.Schema;

public record BackofficeOutboxResponseDTO(

@Schema(description = "Outbox ID", example = "7")
Long outboxId,

@Schema(description = "알림 ID", example = "15")
Long notificationId,

@Schema(description = "수신자 멤버 ID", example = "42")
Long receiverId,

@Schema(description = "알림 타입", example = "STAMP")
String type,

@Schema(description = "알림 제목", example = "스탬프 10개 달성! 이벤트 응모 완료 🎁")
Comment thread
2ghrms marked this conversation as resolved.
String title,

@Schema(description = "알림 미리보기 메시지", example = "스탬프 10개가 적립되어 기프티콘 증정 이벤트에 자동으로 응모되었어요.")
String messagePreview,

@Schema(description = "Outbox 상태 (PENDING / SENDING / DISPATCHED / SENT / FAILED)", example = "FAILED")
String status,

@Schema(description = "재시도 횟수", example = "3")
int retryCount
) {
public static BackofficeOutboxResponseDTO from(NotificationOutbox outbox) {
Notification n = outbox.getNotification();
return new BackofficeOutboxResponseDTO(
outbox.getId(),
n.getId(),
n.getReceiver().getId(),
n.getType().name(),
n.getTitle(),
n.getMessagePreview(),
outbox.getStatus().name(),
outbox.getRetryCount()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.assu.server.domain.backoffice.dto;

import com.assu.server.domain.backoffice.entity.BackofficePushLog;
import io.swagger.v3.oas.annotations.media.Schema;

import java.time.LocalDateTime;

public record BackofficePushLogResponseDTO(

@Schema(description = "로그 ID", example = "1")
Long logId,

@Schema(description = "발송 대상 유형", example = "ALL")
String targetType,

@Schema(description = "푸시 제목", example = "공지사항")
String title,

@Schema(description = "푸시 본문", example = "안녕하세요, 공지사항입니다.")
String body,

@Schema(description = "실제 발송된 수신자 수", example = "150")
int recipientCount,

@Schema(description = "발송 시각")
LocalDateTime sentAt
) {
public static BackofficePushLogResponseDTO from(BackofficePushLog log) {
return new BackofficePushLogResponseDTO(
log.getId(),
log.getTargetType().name(),
log.getTitle(),
log.getBody(),
log.getRecipientCount(),
log.getSentAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.assu.server.domain.backoffice.dto;

import com.assu.server.domain.backoffice.entity.PushTargetType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record BackofficePushSendRequestDTO(

@NotNull
@Schema(description = "발송 대상 유형 (ALL / STUDENT / UNION / PARTNER / INDIVIDUAL)", example = "ALL")
PushTargetType targetType,

@Schema(description = "수신자 멤버 ID (targetType=INDIVIDUAL일 때만 필수)", example = "42")
Long receiverId,

@NotBlank
@Schema(description = "푸시 제목", example = "공지사항")
String title,

@NotBlank
@Schema(description = "푸시 본문", example = "안녕하세요, 공지사항입니다.")
String body,

@Schema(description = "딥링크 URL (선택)", example = "/notice/1")
String deepLink
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.assu.server.domain.backoffice.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BackofficePushLog {

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

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PushTargetType targetType;

private Long receiverId;

@Column(nullable = false)
private String title;

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

private String deepLink;

@Column(nullable = false)
private Long sentByMemberId;

@Column(nullable = false)
private int recipientCount;

@Column(nullable = false)
private LocalDateTime sentAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.assu.server.domain.backoffice.entity;

public enum PushTargetType {
ALL, STUDENT, UNION, PARTNER, INDIVIDUAL

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UNION이 학생회를 의미하는 것이라면 ADMIN을 사용해서 저희 유비쿼터스 용어를 지키면 좋을 것 같습니다

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 유비쿼터스 언어 문서를 작성해놓는 것도 굉장히 좋을 것 같네요! @leesumin0526

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

첨 들어봐서 찾아봤는데 좋을 것 같아요. 저희 용어도 헷갈리는 게 많으니까

}
Loading