diff --git a/README.md b/README.md
index dcf2238e..8522c237 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,36 @@
+
+
+
+토덕 To.duck
+
+ 성인 ADHD인을 위한 토닥임
+
+
+
+
+
+
+
+ Copyright © 2025 To.duck Team
+
+
+
+### 🔗 API
+- [https://api-toduck.seol.pro](https://api-toduck.seol.pro/)
+ - [Swagger UI](https://api-toduck.seol.pro/swagger-ui/index.html)
+ - [에러 코드](https://api-toduck.seol.pro/exception-codes)
+
+
+
### 🧑💻 Authors
+| 강민기 | 박준하 | 설진영 | 최연우 |
+|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|
+|
|
|
|
|
+| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) | [@wafla](https://github.com/wafla) |
-| 강민기 | 박준하 | 설진영 |
-|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|
-|
|
|
|
-| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) |
+
### 📜 Docs
-
- [초기 개발환경 설정](https://kyxxn.notion.site/fdf5a3a523f040328a9c68d4377ff997?pvs=74)
- [개발 가이드](https://kyxxn.notion.site/9a2670768f784ea5bb3ca8f044ede895)
- [API 개요](https://kyxxn.notion.site/API-e775e161efa6459583a0ee0d586c4d19)
diff --git a/sql/init.sql b/sql/init.sql
index 9fa6cbb8..39ec89f6 100644
--- a/sql/init.sql
+++ b/sql/init.sql
@@ -408,6 +408,57 @@ CREATE TABLE events_social
FOREIGN KEY (user_id) REFERENCES users (id)
);
+CREATE TABLE admin
+(
+ id BIGINT PRIMARY KEY auto_increment,
+ user_id BIGINT NOT NULL UNIQUE,
+ display_name VARCHAR(255) NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ deleted_at DATETIME NULL,
+ FOREIGN KEY (user_id) REFERENCES users (id)
+);
+
+CREATE TABLE inquiry
+(
+ id BIGINT PRIMARY KEY auto_increment,
+ user_id BIGINT NOT NULL,
+ type VARCHAR(50) NOT NULL,
+ content VARCHAR(1024) NOT NULL,
+ status VARCHAR(50) NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ deleted_at DATETIME NULL,
+ FOREIGN KEY (user_id) REFERENCES users (id)
+);
+
+CREATE TABLE inquiry_image_file
+(
+ id BIGINT PRIMARY KEY auto_increment,
+ inquiry_id BIGINT NOT NULL,
+ url VARCHAR(1024) NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ deleted_at DATETIME NULL,
+ FOREIGN KEY (inquiry_id) REFERENCES inquiry (id)
+);
+
+CREATE TABLE inquiry_answer
+(
+ id BIGINT PRIMARY KEY auto_increment,
+ admin_id BIGINT NOT NULL,
+ inquiry_id BIGINT NOT NULL,
+ content VARCHAR(1024) NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ deleted_at DATETIME NULL,
+
+ CONSTRAINT uq_inquiry_answer UNIQUE (inquiry_id),
+
+ FOREIGN KEY (admin_id) REFERENCES admin (id),
+ FOREIGN KEY (inquiry_id) REFERENCES inquiry (id)
+);
+
CREATE TABLE account_deletion_log
(
id BIGINT PRIMARY KEY auto_increment,
diff --git a/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java
new file mode 100644
index 00000000..8b1bcad8
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java
@@ -0,0 +1,24 @@
+package im.toduck.domain.admin.common.mapper;
+
+import java.util.List;
+
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.admin.presentation.dto.response.AdminListResponse;
+import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class AdminMapper {
+ public static AdminResponse toAdminResponse(final Admin admin) {
+ return new AdminResponse(
+ admin.getId(),
+ admin.getUser().getId(),
+ admin.getDisplayName()
+ );
+ }
+
+ public static AdminListResponse toAdminListResponse(final List admins) {
+ return AdminListResponse.toListAdminResponse(admins);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java
new file mode 100644
index 00000000..f4500eb0
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java
@@ -0,0 +1,93 @@
+package im.toduck.domain.admin.domain.service;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.domain.admin.common.mapper.AdminMapper;
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.admin.persistence.repository.AdminRepository;
+import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest;
+import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest;
+import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.domain.user.persistence.entity.UserRole;
+import im.toduck.domain.user.persistence.repository.UserRepository;
+import im.toduck.global.exception.CommonException;
+import im.toduck.global.exception.ExceptionCode;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class AdminService {
+ private final UserRepository userRepository;
+ private final AdminRepository adminRepository;
+
+ @Transactional
+ public Admin getAdmin(final Long userId) {
+ return adminRepository.findActiveAdminByUserId(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN));
+ }
+
+ // Role은 Admin이지만 admin 테이블에 등록되어있지 않은 경우 사용합니다.
+ @Transactional
+ public Admin getAdminBySameUser(final Long userId) {
+ return adminRepository.findActiveAdminByUserId(userId)
+ .orElseGet(() -> createDefaultAdmin(userId));
+ }
+
+ private Admin createDefaultAdmin(final Long userId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ if (user.getRole() != UserRole.ADMIN) {
+ throw CommonException.from(ExceptionCode.NOT_FOUND_ADMIN);
+ }
+
+ Admin admin = Admin.builder()
+ .user(user)
+ .displayName("토덕 관리자")
+ .build();
+
+ return adminRepository.save(admin);
+ }
+
+ @Transactional(readOnly = true)
+ public List getAdmins() {
+ List admins = adminRepository.findAllActiveAdmins();
+ return admins.stream()
+ .map(AdminMapper::toAdminResponse)
+ .toList();
+ }
+
+ @Transactional(readOnly = true)
+ public Optional getExistingAdmin(final Long userId) {
+ return adminRepository.findByUserIdIncludeDeleted(userId);
+ }
+
+ @Transactional
+ public Admin createAdmin(final AdminCreateRequest request, final User user) {
+ Admin admin = Admin.builder()
+ .user(user)
+ .displayName(request.displayName())
+ .build();
+
+ return adminRepository.save(admin);
+ }
+
+ @Transactional
+ public void updateAdmin(final Long userId, final AdminUpdateRequest request) {
+ Admin admin = adminRepository.findActiveAdminByUserId(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN));
+ if (request.displayName() != null) {
+ admin.updateDisplayName(request.displayName());
+ }
+ }
+
+ @Transactional
+ public void deleteAdmin(final Admin admin) {
+ adminRepository.delete(admin);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java
new file mode 100644
index 00000000..3b1c1e6d
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java
@@ -0,0 +1,93 @@
+package im.toduck.domain.admin.domain.usecase;
+
+import java.util.List;
+
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.domain.admin.common.mapper.AdminMapper;
+import im.toduck.domain.admin.domain.service.AdminService;
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest;
+import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest;
+import im.toduck.domain.admin.presentation.dto.response.AdminListResponse;
+import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
+import im.toduck.domain.user.domain.service.UserService;
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.domain.user.persistence.entity.UserRole;
+import im.toduck.global.annotation.UseCase;
+import im.toduck.global.exception.CommonException;
+import im.toduck.global.exception.ExceptionCode;
+import lombok.RequiredArgsConstructor;
+
+@UseCase
+@RequiredArgsConstructor
+public class AdminUseCase {
+ private final AdminService adminService;
+ private final UserService userService;
+
+ @Transactional
+ public AdminResponse getAdmin(final Long userId) {
+ Admin admin = adminService.getAdmin(userId);
+
+ return AdminMapper.toAdminResponse(admin);
+ }
+
+ @Transactional
+ public AdminListResponse getAdmins() {
+ List admins = adminService.getAdmins();
+
+ return AdminMapper.toAdminListResponse(admins);
+ }
+
+ @Transactional
+ public Admin createAdmin(final AdminCreateRequest request) {
+ User user = userService.getUserById(request.userId())
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ Admin existingAdmin = adminService.getExistingAdmin(user.getId()).orElse(null);
+
+ // 이미 활성화된 관리자인 경우
+ if (existingAdmin != null && existingAdmin.getDeletedAt() == null) {
+ throw CommonException.from(ExceptionCode.DUPLICATE_ADMIN);
+ }
+
+ // 삭제된 관리자 복구
+ if (existingAdmin != null) {
+ existingAdmin.revive();
+ existingAdmin.updateDisplayName(request.displayName());
+
+ if (user.getRole() == UserRole.USER) {
+ user.promoteToAdmin();
+ }
+
+ return existingAdmin;
+ }
+
+ // 신규 관리자
+ Admin admin = adminService.createAdmin(request, user);
+
+ if (user.getRole() == UserRole.USER) {
+ user.promoteToAdmin();
+ }
+
+ return admin;
+ }
+
+ @Transactional
+ public void updateAdmin(final Long userId, final AdminUpdateRequest request) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ adminService.updateAdmin(userId, request);
+ }
+
+ @Transactional
+ public void deleteAdmin(final Long userId) {
+ Admin admin = adminService.getAdmin(userId);
+
+ User user = admin.getUser();
+ user.demoteToUser();
+
+ adminService.deleteAdmin(admin);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java b/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java
new file mode 100644
index 00000000..ac0203e8
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java
@@ -0,0 +1,51 @@
+package im.toduck.domain.admin.persistence.entity;
+
+import org.hibernate.annotations.SQLDelete;
+
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.global.base.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "admin")
+@Getter
+@NoArgsConstructor
+@SQLDelete(sql = "UPDATE admin SET deleted_at = NOW() where id=?")
+public class Admin extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false, unique = true)
+ private User user;
+
+ @Column(length = 255, nullable = false)
+ private String displayName;
+
+ @Builder
+ private Admin(User user,
+ String displayName) {
+ this.user = user;
+ this.displayName = displayName;
+ }
+
+ public void updateDisplayName(final String displayName) {
+ this.displayName = displayName;
+ }
+
+ public void revive() {
+ this.deletedAt = null;
+ }
+}
diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java b/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java
new file mode 100644
index 00000000..513a1bcc
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java
@@ -0,0 +1,11 @@
+package im.toduck.domain.admin.persistence.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.admin.persistence.repository.querydsl.AdminRepositoryCustom;
+
+@Repository
+public interface AdminRepository extends JpaRepository, AdminRepositoryCustom {
+}
diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java
new file mode 100644
index 00000000..fd96da3a
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java
@@ -0,0 +1,14 @@
+package im.toduck.domain.admin.persistence.repository.querydsl;
+
+import java.util.List;
+import java.util.Optional;
+
+import im.toduck.domain.admin.persistence.entity.Admin;
+
+public interface AdminRepositoryCustom {
+ Optional findActiveAdminByUserId(Long userId);
+
+ List findAllActiveAdmins();
+
+ Optional findByUserIdIncludeDeleted(Long userId);
+}
diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java
new file mode 100644
index 00000000..39bf181f
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java
@@ -0,0 +1,53 @@
+package im.toduck.domain.admin.persistence.repository.querydsl;
+
+import java.util.List;
+import java.util.Optional;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.admin.persistence.entity.QAdmin;
+import im.toduck.domain.user.persistence.entity.QUser;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class AdminRepositoryCustomImpl implements AdminRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+ private final QAdmin admin = QAdmin.admin;
+ private final QUser user = QUser.user;
+
+ @Override
+ public Optional findActiveAdminByUserId(final Long userId) {
+ Admin result = queryFactory
+ .selectFrom(admin)
+ .join(admin.user, user).fetchJoin()
+ .where(
+ user.id.eq(userId),
+ admin.deletedAt.isNull()
+ )
+ .fetchOne();
+
+ return Optional.ofNullable(result);
+ }
+
+ @Override
+ public List findAllActiveAdmins() {
+ return queryFactory
+ .selectFrom(admin)
+ .where(admin.deletedAt.isNull())
+ .fetch();
+ }
+
+ @Override
+ public Optional findByUserIdIncludeDeleted(final Long userId) {
+ Admin result = queryFactory
+ .selectFrom(admin)
+ .where(
+ admin.user.id.eq(userId)
+ )
+ .fetchOne();
+
+ return Optional.ofNullable(result);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java b/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java
new file mode 100644
index 00000000..140dae08
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java
@@ -0,0 +1,129 @@
+package im.toduck.domain.admin.presentation.api;
+
+import java.util.Map;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest;
+import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest;
+import im.toduck.domain.admin.presentation.dto.response.AdminListResponse;
+import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
+import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation;
+import im.toduck.global.annotation.swagger.ApiResponseExplanations;
+import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation;
+import im.toduck.global.exception.ExceptionCode;
+import im.toduck.global.presentation.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Admin")
+public interface AdminApi {
+ @Operation(
+ summary = "관리자 조회",
+ description = "관리자를 조회합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ responseClass = AdminResponse.class,
+ description = "관리자 조회 성공. 관리자 데이터를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN)
+ }
+ )
+ ResponseEntity> getAdmin(
+ @PathVariable final Long userId
+ );
+
+ @Operation(
+ summary = "관리자 목록 조회",
+ description = "관리자 목록을 조회합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ responseClass = AdminListResponse.class,
+ description = "관리자 목록 조회 성공. 관리자 목록 데이터를 반환합니다."
+ )
+ )
+ ResponseEntity> getAdmins();
+
+ @Operation(
+ summary = "관리자 생성",
+ description = """
+ 관리자를 생성합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ "userId": 관리자에 등록할 사용자 ID를 입력합니다. Role이 User에서 Admin으로 승격됩니다.
+ "displayName": 문의 답변 등에서 사용자들에게 보여질 이름입니다.
+ """
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "관리자 생성 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER),
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.DUPLICATE_ADMIN)
+ }
+ )
+ ResponseEntity>> createAdmin(
+ @RequestBody final AdminCreateRequest request
+ );
+
+ @Operation(
+ summary = "관리자 정보 수정",
+ description = """
+ 관리자 정보를 수정합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ Path Variable에는 관리자의 사용자 ID를 입력합니다.
+
+ "displayName": 문의 답변 등에서 사용자들에게 보여질 이름입니다. null인 경우 수정되지 않습니다.
+ """
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "관리자 정보 수정 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER),
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN)
+ }
+ )
+ ResponseEntity>> updateAdmin(
+ @Parameter(
+ name = "userId",
+ description = "수정할 관리자의 사용자 ID",
+ required = true,
+ example = "1"
+ )
+ @PathVariable final Long userId,
+ @RequestBody final AdminUpdateRequest request
+ );
+
+ @Operation(
+ summary = "관리자 정보 삭제",
+ description = """
+ 관리자 정보를 삭제합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ Path Variable에는 관리자의 사용자 ID를 입력합니다.
+ 해당 사용자의 관리자 정보가 논리 삭제되며 관리자 권한 또한 제거됩니다.
+ """
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "관리자 정보 삭제 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER),
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN)
+ }
+ )
+ ResponseEntity>> deleteAdmin(
+ @Parameter(
+ name = "userId",
+ description = "삭제할 관리자의 사용자 ID",
+ required = true,
+ example = "1"
+ )
+ @PathVariable final Long userId
+ );
+}
diff --git a/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java
new file mode 100644
index 00000000..a1e99e65
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java
@@ -0,0 +1,86 @@
+package im.toduck.domain.admin.presentation.controller;
+
+import java.util.Map;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.RestController;
+
+import im.toduck.domain.admin.domain.usecase.AdminUseCase;
+import im.toduck.domain.admin.presentation.api.AdminApi;
+import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest;
+import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest;
+import im.toduck.domain.admin.presentation.dto.response.AdminListResponse;
+import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
+import im.toduck.global.presentation.ApiResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/admin")
+public class AdminController implements AdminApi {
+
+ private final AdminUseCase adminUseCase;
+
+ @Override
+ @GetMapping("/{userId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> getAdmin(
+ @PathVariable final Long userId
+ ) {
+ AdminResponse response = adminUseCase.getAdmin(userId);
+
+ return ResponseEntity.ok(ApiResponse.createSuccess(response));
+ }
+
+ @Override
+ @GetMapping
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> getAdmins() {
+ AdminListResponse response = adminUseCase.getAdmins();
+
+ return ResponseEntity.ok(ApiResponse.createSuccess(response));
+ }
+
+ @Override
+ @PostMapping
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity>> createAdmin(
+ @RequestBody @Valid final AdminCreateRequest request
+ ) {
+ adminUseCase.createAdmin(request);
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+
+ @Override
+ @PatchMapping("/{userId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity>> updateAdmin(
+ @PathVariable final Long userId,
+ @RequestBody @Valid final AdminUpdateRequest request
+ ) {
+ adminUseCase.updateAdmin(userId, request);
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+
+ @Override
+ @DeleteMapping("/{userId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity>> deleteAdmin(
+ @PathVariable final Long userId
+ ) {
+ adminUseCase.deleteAdmin(userId);
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+}
diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java
new file mode 100644
index 00000000..e382b87c
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java
@@ -0,0 +1,21 @@
+package im.toduck.domain.admin.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "관리자 생성 요청 DTO")
+public record AdminCreateRequest(
+ @NotNull(message = "사용자 ID는 비어있을 수 없습니다.")
+ @Schema(description = "사용자 ID", example = "1")
+ Long userId,
+
+ @NotBlank(message = "관리자 표시명은 비어있을 수 없습니다.")
+ @Size(max = 255, message = "관리자 표시명은 255자를 초과할 수 없습니다.")
+ @Schema(description = "관리자 표시명", example = "토덕 관리자")
+ String displayName
+) {
+}
diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java
new file mode 100644
index 00000000..e2b5491a
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java
@@ -0,0 +1,16 @@
+package im.toduck.domain.admin.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "관리자 수정 요청 DTO")
+public record AdminUpdateRequest(
+ @NotBlank(message = "관리자 표시명은 비어있을 수 없습니다.")
+ @Size(max = 255, message = "관리자 표시명은 255자를 초과할 수 없습니다.")
+ @Schema(description = "관리자 표시명", example = "토덕 관리자")
+ String displayName
+) {
+}
diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java
new file mode 100644
index 00000000..53e6c566
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java
@@ -0,0 +1,21 @@
+package im.toduck.domain.admin.presentation.dto.response;
+
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "관리자 목록 응답")
+public record AdminListResponse(
+ @Schema(description = "관리자 목록")
+ List adminDtos
+) {
+ public static AdminListResponse toListAdminResponse(
+ final List admins
+ ) {
+ return AdminListResponse.builder()
+ .adminDtos(admins)
+ .build();
+ }
+}
diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java
new file mode 100644
index 00000000..7ae769ce
--- /dev/null
+++ b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java
@@ -0,0 +1,18 @@
+package im.toduck.domain.admin.presentation.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "관리자 응답")
+public record AdminResponse(
+ @Schema(description = "관리자 ID", example = "1")
+ Long adminId,
+
+ @Schema(description = "사용자 ID", example = "1")
+ Long userId,
+
+ @Schema(description = "관리자 표시명", example = "토덕 관리자")
+ String displayName
+) {
+}
diff --git a/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java b/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java
index bd81d514..8aa4d5b1 100644
--- a/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java
+++ b/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java
@@ -62,9 +62,6 @@ public Optional getDiaryById(final Long diaryId) {
@Transactional
public void deleteDiary(final Diary diary) {
- List imageFiles = diaryImageRepository.findAllByDiary(diary);
- imageFiles.forEach(DiaryImage::softDelete);
-
diaryRepository.delete(diary);
}
diff --git a/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java b/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java
index 59b1ba23..c0d6a1d9 100644
--- a/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java
+++ b/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java
@@ -8,6 +8,7 @@
import im.toduck.domain.diary.common.mapper.DiaryMapper;
import im.toduck.domain.diary.domain.service.DiaryService;
import im.toduck.domain.diary.persistence.entity.Diary;
+import im.toduck.domain.diary.persistence.entity.DiaryImage;
import im.toduck.domain.diary.presentation.dto.request.DiaryCreateRequest;
import im.toduck.domain.diary.presentation.dto.request.DiaryUpdateRequest;
import im.toduck.domain.diary.presentation.dto.response.DiaryListResponse;
@@ -55,6 +56,8 @@ public void deleteDiary(Long userId, Long diaryId) {
throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_DIARY);
}
+ diary.getDiaryImages().forEach(DiaryImage::softDelete);
+
diaryService.deleteDiary(diary);
log.info("일기 삭제 - UserId: {}, DiaryId: {}", userId, diaryId);
}
diff --git a/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java b/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java
index b43432a6..14b31dfc 100644
--- a/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java
+++ b/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java
@@ -56,7 +56,7 @@ public class Diary extends BaseEntity {
@Lob
private String memo;
- @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL)
private List diaryImages = new ArrayList<>();
@OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true)
diff --git a/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java b/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java
index 05c897f0..df2c27d6 100644
--- a/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java
+++ b/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java
@@ -2,9 +2,6 @@
import java.time.LocalDateTime;
-import org.hibernate.annotations.SQLDelete;
-import org.hibernate.annotations.SQLRestriction;
-
import im.toduck.global.base.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -22,8 +19,6 @@
@Table(name = "diary_image_file")
@Getter
@NoArgsConstructor
-@SQLDelete(sql = "UPDATE diary_image_file SET deleted_at = NOW() where id=?")
-@SQLRestriction(value = "deleted_at is NULL")
public class DiaryImage extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
diff --git a/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java b/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java
index eb2b41b9..0f516609 100644
--- a/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java
+++ b/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java
@@ -43,7 +43,7 @@ public EventsDetail createEventsDetail(final EventsDetailCreateRequest request)
}
@Transactional
- public void addEventsDetailImges(final EventsDetail eventsDetail, final List imgUrls) {
+ public void addEventsDetailImages(final EventsDetail eventsDetail, final List imgUrls) {
List safeImgs = Optional.ofNullable(imgUrls).orElse(Collections.emptyList());
List eventsDetailImgs = safeImgs.stream()
@@ -95,7 +95,7 @@ public void updateEventsDetail(
if (request.eventsDetailImgs() != null && !request.eventsDetailImgs().isEmpty()) {
eventsDetailImgRepository.deleteAllByEventsDetail(eventsDetail);
- addEventsDetailImges(eventsDetail, request.eventsDetailImgs());
+ addEventsDetailImages(eventsDetail, request.eventsDetailImgs());
} else {
eventsDetailImgRepository.deleteAllByEventsDetail(eventsDetail);
}
diff --git a/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java b/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java
index 7a837186..fb0b829d 100644
--- a/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java
+++ b/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java
@@ -7,6 +7,7 @@
import im.toduck.domain.events.detail.common.mapper.EventsDetailMapper;
import im.toduck.domain.events.detail.domain.service.EventsDetailService;
import im.toduck.domain.events.detail.persistence.entity.EventsDetail;
+import im.toduck.domain.events.detail.persistence.entity.EventsDetailImg;
import im.toduck.domain.events.detail.presentation.dto.request.EventsDetailCreateRequest;
import im.toduck.domain.events.detail.presentation.dto.request.EventsDetailUpdateRequest;
import im.toduck.domain.events.detail.presentation.dto.response.EventsDetailListResponse;
@@ -47,7 +48,7 @@ public EventsDetail createEventsDetail(final EventsDetailCreateRequest request,
}
EventsDetail eventsDetail = eventsDetailService.createEventsDetail(request);
- eventsDetailService.addEventsDetailImges(eventsDetail, request.eventsDetailImgs());
+ eventsDetailService.addEventsDetailImages(eventsDetail, request.eventsDetailImgs());
return eventsDetail;
}
@@ -76,6 +77,8 @@ public void deleteEventsDetail(final Long eventsDetailId, final Long userId) {
EventsDetail eventsDetail = eventsDetailService.findById(eventsDetailId)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_EVENTS_DETAIL));
+ eventsDetail.getEventsDetailImgs().forEach(EventsDetailImg::softDelete);
+
eventsDetailService.deleteEventsDetail(eventsDetail);
}
}
diff --git a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java
index bd28f115..2a00d789 100644
--- a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java
+++ b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java
@@ -47,7 +47,7 @@ public class EventsDetail extends BaseEntity {
@Column(length = 63)
private String buttonText;
- @OneToMany(mappedBy = "eventsDetail", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OneToMany(mappedBy = "eventsDetail", cascade = CascadeType.ALL)
private List eventsDetailImgs = new ArrayList<>();
@Builder
diff --git a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java
index 19f30f01..6d9b2ce2 100644
--- a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java
+++ b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java
@@ -1,11 +1,11 @@
package im.toduck.domain.events.detail.persistence.entity;
-import org.hibernate.annotations.SQLDelete;
-import org.hibernate.annotations.SQLRestriction;
+import java.time.LocalDateTime;
import im.toduck.global.base.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@@ -20,14 +20,12 @@
@Table(name = "events_detail_img")
@Getter
@NoArgsConstructor
-@SQLDelete(sql = "UPDATE events_detail_img SET deleted_at = NOW() where id=?")
-@SQLRestriction(value = "deleted_at is NULL")
public class EventsDetailImg extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- @ManyToOne
+ @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "events_detail_id", nullable = false)
private EventsDetail eventsDetail;
@@ -47,4 +45,8 @@ public void updateEventsDetail(final EventsDetail eventsDetail) {
public void updateDetailImgUrl(final String detailImgUrl) {
this.detailImgUrl = detailImgUrl;
}
+
+ public void softDelete() {
+ this.deletedAt = LocalDateTime.now();
+ }
}
diff --git a/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java b/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java
index 7a27786f..8b07a5e6 100644
--- a/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java
+++ b/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java
@@ -26,7 +26,7 @@ public record EventsDetailCreateRequest(
@Schema(description = "버튼 내용", example = "당첨 확인하기")
String buttonText,
- @Schema(description = "이벤트 디테일 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]")
+ @Schema(description = "이벤트 디테일 사진 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]")
List eventsDetailImgs
) {
diff --git a/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java
new file mode 100644
index 00000000..3b51a794
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java
@@ -0,0 +1,20 @@
+package im.toduck.domain.inquiry.common.mapper;
+
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryImage;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class InquiryImgMapper {
+ public static InquiryImage toInquiryImg(Inquiry inquiry, String imgUrls) {
+ return InquiryImage.builder()
+ .inquiry(inquiry)
+ .url(imgUrls)
+ .build();
+ }
+
+ public static String fromInquiryImg(InquiryImage inquiryImage) {
+ return inquiryImage.getUrl();
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java
new file mode 100644
index 00000000..038c23e7
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java
@@ -0,0 +1,51 @@
+package im.toduck.domain.inquiry.common.mapper;
+
+import java.util.List;
+
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer;
+import im.toduck.domain.inquiry.persistence.entity.Status;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse;
+import im.toduck.domain.user.persistence.entity.User;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class InquiryMapper {
+
+ public static InquiryResponse fromInquiry(final Inquiry inquiry) {
+
+ InquiryAnswer answer = inquiry.getInquiryAnswer();
+
+ return new InquiryResponse(
+ inquiry.getId(),
+ inquiry.getType(),
+ inquiry.getContent(),
+ inquiry.getStatus(),
+ inquiry.getCreatedAt(),
+ inquiry.getInquiryImages().stream()
+ .map(InquiryImgMapper::fromInquiryImg)
+ .toList(),
+
+ answer != null ? answer.getId() : null,
+ answer != null ? answer.getAdmin().getDisplayName() : null,
+ answer != null ? answer.getContent() : null,
+ answer != null ? answer.getCreatedAt() : null
+ );
+ }
+
+ public static InquiryListResponse toListInquiryResponse(final List inquiries) {
+ return InquiryListResponse.toListInquiryResponse(inquiries);
+ }
+
+ public static Inquiry toInquiry(final InquiryCreateRequest request, final User user) {
+ return Inquiry.builder()
+ .user(user)
+ .type(request.type())
+ .content(request.content())
+ .status(Status.PENDING)
+ .build();
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java
new file mode 100644
index 00000000..65155333
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java
@@ -0,0 +1,93 @@
+package im.toduck.domain.inquiry.domain.service;
+
+import java.util.Optional;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer;
+import im.toduck.domain.inquiry.persistence.entity.Status;
+import im.toduck.domain.inquiry.persistence.repository.InquiryAnswerRepository;
+import im.toduck.domain.inquiry.persistence.repository.InquiryRepository;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest;
+import im.toduck.global.exception.CommonException;
+import im.toduck.global.exception.ExceptionCode;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class InquiryAnswerService {
+ private final InquiryRepository inquiryRepository;
+ private final InquiryAnswerRepository inquiryAnswerRepository;
+
+ @Transactional
+ public Optional findById(final Long inquiryAnswerId) {
+ return inquiryAnswerRepository.findById(inquiryAnswerId);
+ }
+
+ @Transactional
+ public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest request, final Admin admin) {
+ Inquiry inquiry = inquiryRepository.findById(request.inquiryId())
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY));
+
+ Optional anyAnswerOpt =
+ inquiryAnswerRepository.findAnyByInquiryIdIncludingDeleted(inquiry.getId());
+
+ if (anyAnswerOpt.isPresent()) {
+ InquiryAnswer existing = anyAnswerOpt.get();
+
+ if (existing.getDeletedAt() == null) {
+ throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY);
+ }
+
+ existing.revive(request.content(), admin);
+ existing.setInquiry(inquiry);
+ inquiry.addAnswer(existing);
+ inquiry.changeStatus(Status.ANSWERED);
+ return inquiryAnswerRepository.save(existing);
+ }
+
+ InquiryAnswer newAnswer = InquiryAnswer.builder()
+ .admin(admin)
+ .inquiry(inquiry)
+ .content(request.content())
+ .build();
+
+ inquiry.addAnswer(newAnswer);
+ inquiry.changeStatus(Status.ANSWERED);
+
+ return newAnswer;
+ }
+
+ @Transactional
+ public InquiryAnswer updateInquiryAnswer(
+ final Long inquiryId,
+ final InquiryAnswerUpdateRequest request,
+ final Admin admin) {
+ Inquiry inquiry = inquiryRepository.findById(inquiryId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY));
+
+ InquiryAnswer inquiryAnswer = inquiry.getInquiryAnswer();
+ inquiryAnswer.updateAnswer(request.content(), admin);
+ return inquiryAnswer;
+ }
+
+ @Transactional
+ public void deleteInquiryAnswer(final Long inquiryId) {
+ Inquiry inquiry = inquiryRepository.findById(inquiryId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY));
+
+ InquiryAnswer inquiryAnswer = inquiry.getInquiryAnswer();
+ if (inquiryAnswer == null) {
+ throw CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY_ANSWER);
+ }
+
+ inquiry.changeStatus(Status.PENDING);
+ inquiry.removeAnswer();
+
+ inquiryAnswerRepository.delete(inquiryAnswer);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java
new file mode 100644
index 00000000..05ac4c06
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java
@@ -0,0 +1,91 @@
+package im.toduck.domain.inquiry.domain.service;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.domain.inquiry.common.mapper.InquiryImgMapper;
+import im.toduck.domain.inquiry.common.mapper.InquiryMapper;
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryImage;
+import im.toduck.domain.inquiry.persistence.repository.InquiryImgRepository;
+import im.toduck.domain.inquiry.persistence.repository.InquiryRepository;
+import im.toduck.domain.inquiry.persistence.repository.querydsl.InquiryRepositoryCustom;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse;
+import im.toduck.domain.user.persistence.entity.User;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class InquiryService {
+ private final InquiryRepository inquiryRepository;
+ private final InquiryImgRepository inquiryImgRepository;
+ private final InquiryRepositoryCustom inquiryRepositoryCustom;
+
+ @Transactional(readOnly = true)
+ public List getInquiries(final Long userId) {
+ List inquiries = inquiryRepositoryCustom.findWithImgs(userId);
+ return inquiries.stream()
+ .map(InquiryMapper::fromInquiry)
+ .toList();
+ }
+
+ @Transactional
+ public Inquiry createInquiry(final InquiryCreateRequest request, final User user) {
+ Inquiry inquiry = InquiryMapper.toInquiry(request, user);
+ inquiryRepository.save(inquiry);
+ return inquiry;
+ }
+
+ @Transactional
+ public void addInquiryImages(final Inquiry inquiry, final List imgUrls) {
+ List safeImgs = Optional.ofNullable(imgUrls).orElse(Collections.emptyList());
+
+ List inquiryImgs = safeImgs.stream()
+ .map(url -> InquiryImgMapper.toInquiryImg(inquiry, url))
+ .toList();
+ inquiryImgRepository.saveAll(inquiryImgs);
+ }
+
+ @Transactional
+ public Optional getInquiryById(final Long inquiryId) {
+ return inquiryRepository.findById(inquiryId);
+ }
+
+ @Transactional
+ public void updateInquiry(final InquiryUpdateRequest request, final Inquiry inquiry) {
+ if (request.type() != null) {
+ inquiry.updateType(request.type());
+ }
+
+ if (request.content() != null) {
+ inquiry.updateContent(request.content());
+ }
+
+ if (request.inquiryImgs() != null) {
+ List images = inquiryImgRepository.findAllByInquiry(inquiry);
+ images.forEach(inquiryImgRepository::delete);
+ if (!request.inquiryImgs().isEmpty()) {
+ addInquiryImages(inquiry, request.inquiryImgs());
+ }
+ }
+ }
+
+ @Transactional
+ public void deleteInquiry(final Inquiry inquiry) {
+ inquiryRepository.delete(inquiry);
+ }
+
+ @Transactional(readOnly = true)
+ public List getAllInquiries() {
+ List inquiries = inquiryRepositoryCustom.findAllWithImgs();
+ return inquiries.stream()
+ .map(InquiryMapper::fromInquiry)
+ .toList();
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java
new file mode 100644
index 00000000..620657e8
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java
@@ -0,0 +1,52 @@
+package im.toduck.domain.inquiry.domain.usecase;
+
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.domain.admin.domain.service.AdminService;
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.inquiry.domain.service.InquiryAnswerService;
+import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest;
+import im.toduck.domain.user.domain.service.UserService;
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.global.annotation.UseCase;
+import im.toduck.global.exception.CommonException;
+import im.toduck.global.exception.ExceptionCode;
+import lombok.RequiredArgsConstructor;
+
+@UseCase
+@RequiredArgsConstructor
+public class InquiryAnswerUseCase {
+ private final UserService userService;
+ private final AdminService adminService;
+ private final InquiryAnswerService inquiryAnswerService;
+
+ @Transactional
+ public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest request, final Long userId) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ Admin admin = adminService.getAdminBySameUser(user.getId());
+
+ return inquiryAnswerService.createInquiryAnswer(request, admin);
+ }
+
+ @Transactional
+ public InquiryAnswer updateInquiryAnswer(
+ final Long inquiryId,
+ final InquiryAnswerUpdateRequest request,
+ final Long userId) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ Admin admin = adminService.getAdminBySameUser(userId);
+
+ return inquiryAnswerService.updateInquiryAnswer(inquiryId, request, admin);
+ }
+
+ @Transactional
+ public void deleteInquiryAnswer(final Long inquiryId) {
+ inquiryAnswerService.deleteInquiryAnswer(inquiryId);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java
new file mode 100644
index 00000000..0738ab56
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java
@@ -0,0 +1,102 @@
+package im.toduck.domain.inquiry.domain.usecase;
+
+import java.util.List;
+
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.domain.inquiry.common.mapper.InquiryMapper;
+import im.toduck.domain.inquiry.domain.service.InquiryService;
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse;
+import im.toduck.domain.user.domain.service.UserService;
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.global.annotation.UseCase;
+import im.toduck.global.exception.CommonException;
+import im.toduck.global.exception.ExceptionCode;
+import lombok.RequiredArgsConstructor;
+
+@UseCase
+@RequiredArgsConstructor
+public class InquiryUseCase {
+ private final UserService userService;
+ private final InquiryService inquiryService;
+
+ @Transactional(readOnly = true)
+ public InquiryListResponse getInquiries(Long userId) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ List inquiries = inquiryService.getInquiries(userId);
+
+ return InquiryMapper.toListInquiryResponse(inquiries);
+ }
+
+ @Transactional
+ public Inquiry createInquiry(final InquiryCreateRequest request, final Long userId) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ Inquiry inquiry = inquiryService.createInquiry(request, user);
+ inquiryService.addInquiryImages(inquiry, request.inquiryImgs());
+
+ return inquiry;
+ }
+
+ @Transactional
+ public Inquiry updateInquiry(
+ final Long inquiryId,
+ final InquiryUpdateRequest request,
+ final Long userId) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ Inquiry inquiry = inquiryService.getInquiryById(inquiryId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY));
+
+ if (!inquiry.getUser().getId().equals(userId)) {
+ throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_INQUIRY);
+ }
+
+ InquiryAnswer answer = inquiry.getInquiryAnswer();
+ if (answer != null && answer.getDeletedAt() == null) {
+ throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY);
+ }
+
+ inquiryService.updateInquiry(request, inquiry);
+ return inquiry;
+ }
+
+ @Transactional
+ public void deleteInquiry(final Long inquiryId, final Long userId) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ Inquiry inquiry = inquiryService.getInquiryById(inquiryId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY));
+
+ if (!inquiry.getUser().getId().equals(userId)) {
+ throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_INQUIRY);
+ }
+
+ InquiryAnswer answer = inquiry.getInquiryAnswer();
+ if (answer != null && answer.getDeletedAt() == null) {
+ throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY);
+ }
+
+ inquiryService.deleteInquiry(inquiry);
+ }
+
+ @Transactional(readOnly = true)
+ public InquiryListResponse getAllInquiries(final Long userId) {
+ User user = userService.getUserById(userId)
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
+
+ List inquiries = inquiryService.getAllInquiries();
+
+ return InquiryMapper.toListInquiryResponse(inquiries);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java
new file mode 100644
index 00000000..d8c1410d
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java
@@ -0,0 +1,95 @@
+package im.toduck.domain.inquiry.persistence.entity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.global.base.entity.BaseEntity;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "inquiry")
+@Getter
+@NoArgsConstructor
+@SQLDelete(sql = "UPDATE inquiry SET deleted_at = NOW() where id=?")
+@SQLRestriction(value = "deleted_at is NULL")
+public class Inquiry extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private Type type;
+
+ @Column(length = 1024, nullable = false)
+ private String content;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private Status status;
+
+ @OneToMany(mappedBy = "inquiry", cascade = CascadeType.ALL)
+ private List inquiryImages = new ArrayList<>();
+
+ @OneToOne(mappedBy = "inquiry", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
+ private InquiryAnswer inquiryAnswer;
+
+ @Builder
+ private Inquiry(User user,
+ Type type,
+ String content,
+ Status status) {
+ this.user = user;
+ this.type = type;
+ this.content = content;
+ this.status = status;
+ }
+
+ public void updateType(Type type) {
+ this.type = type;
+ }
+
+ public void updateContent(String content) {
+ this.content = content;
+ }
+
+ public void changeStatus(final Status status) {
+ this.status = status;
+ }
+
+ public void addAnswer(final InquiryAnswer answer) {
+ this.inquiryAnswer = answer;
+ answer.setInquiry(this);
+ }
+
+ public void removeAnswer() {
+ if (this.inquiryAnswer != null) {
+ this.inquiryAnswer.delete();
+ this.inquiryAnswer = null;
+ }
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java
new file mode 100644
index 00000000..5fadf2b7
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java
@@ -0,0 +1,69 @@
+package im.toduck.domain.inquiry.persistence.entity;
+
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.global.base.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(name = "inquiry_answer")
+@Getter
+@NoArgsConstructor
+@SQLDelete(sql = "UPDATE inquiry_answer SET deleted_at = NOW() where id=?")
+@SQLRestriction(value = "deleted_at is NULL")
+public class InquiryAnswer extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "admin_id", nullable = false)
+ private Admin admin;
+
+ @Setter
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "inquiry_id", nullable = false, unique = true)
+ private Inquiry inquiry;
+
+ @Column(length = 1024, nullable = false)
+ private String content;
+
+ @Builder
+ private InquiryAnswer(Admin admin, Inquiry inquiry, String content) {
+ this.admin = admin;
+ this.inquiry = inquiry;
+ this.content = content;
+ }
+
+ public void updateAnswer(final String content, final Admin admin) {
+ this.content = content;
+ this.admin = admin;
+ }
+
+ public void revive(final String content, final Admin admin) {
+ this.deletedAt = null;
+ this.content = content;
+ this.admin = admin;
+ }
+
+ public void delete() {
+ this.deletedAt = LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java
new file mode 100644
index 00000000..10fef170
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java
@@ -0,0 +1,49 @@
+package im.toduck.domain.inquiry.persistence.entity;
+
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
+
+import im.toduck.global.base.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "inquiry_image_file")
+@Getter
+@NoArgsConstructor
+@SQLDelete(sql = "UPDATE inquiry_image_file SET deleted_at = NOW() where id=?")
+@SQLRestriction(value = "deleted_at is NULL")
+public class InquiryImage extends BaseEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "inquiry_id", nullable = false)
+ private Inquiry inquiry;
+
+ @Column(name = "url", length = 1024, nullable = false)
+ private String url;
+
+ @Builder
+ private InquiryImage(Inquiry inquiry, String url) {
+ this.inquiry = inquiry;
+ this.url = url;
+ }
+
+ public void softDelete() {
+ this.deletedAt = LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java
new file mode 100644
index 00000000..948ae0c8
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java
@@ -0,0 +1,6 @@
+package im.toduck.domain.inquiry.persistence.entity;
+
+public enum Status {
+ PENDING,
+ ANSWERED
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java
new file mode 100644
index 00000000..5d591a75
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java
@@ -0,0 +1,8 @@
+package im.toduck.domain.inquiry.persistence.entity;
+
+public enum Type {
+ ERROR,
+ USAGE,
+ SUGGESTION,
+ ETC
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java
new file mode 100644
index 00000000..26ce02de
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java
@@ -0,0 +1,22 @@
+package im.toduck.domain.inquiry.persistence.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer;
+
+@Repository
+public interface InquiryAnswerRepository extends JpaRepository {
+ boolean existsByInquiry(Inquiry inquiry);
+
+ @Query(value = """
+ SELECT *
+ FROM inquiry_answer
+ WHERE inquiry_id = :inquiryId
+ """, nativeQuery = true)
+ Optional findAnyByInquiryIdIncludingDeleted(Long inquiryId);
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java
new file mode 100644
index 00000000..06555d07
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java
@@ -0,0 +1,14 @@
+package im.toduck.domain.inquiry.persistence.repository;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryImage;
+
+@Repository
+public interface InquiryImgRepository extends JpaRepository {
+ List findAllByInquiry(Inquiry inquiry);
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java
new file mode 100644
index 00000000..57d772b2
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java
@@ -0,0 +1,10 @@
+package im.toduck.domain.inquiry.persistence.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+
+@Repository
+public interface InquiryRepository extends JpaRepository {
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java
new file mode 100644
index 00000000..62036b74
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java
@@ -0,0 +1,11 @@
+package im.toduck.domain.inquiry.persistence.repository.querydsl;
+
+import java.util.List;
+
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+
+public interface InquiryRepositoryCustom {
+ List findWithImgs(final Long userId);
+
+ List findAllWithImgs();
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java
new file mode 100644
index 00000000..00357666
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java
@@ -0,0 +1,57 @@
+package im.toduck.domain.inquiry.persistence.repository.querydsl;
+
+import java.util.List;
+
+import org.springframework.stereotype.Repository;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.QInquiry;
+import im.toduck.domain.inquiry.persistence.entity.QInquiryAnswer;
+import im.toduck.domain.inquiry.persistence.entity.QInquiryImage;
+import lombok.RequiredArgsConstructor;
+
+@Repository
+@RequiredArgsConstructor
+public class InquiryRepositoryCustomImpl implements InquiryRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findWithImgs(final Long userId) {
+ QInquiry iq = QInquiry.inquiry;
+ QInquiryImage iqi = QInquiryImage.inquiryImage;
+ QInquiryAnswer iqa = QInquiryAnswer.inquiryAnswer;
+
+ return queryFactory
+ .selectFrom(iq)
+ .leftJoin(iq.inquiryImages, iqi).fetchJoin()
+ .leftJoin(iq.inquiryAnswer, iqa).fetchJoin()
+ .leftJoin(iqa.admin).fetchJoin()
+ .where(
+ iq.user.id.eq(userId),
+ iq.deletedAt.isNull()
+ )
+ .orderBy(iq.createdAt.desc())
+ .distinct()
+ .fetch();
+ }
+
+ @Override
+ public List findAllWithImgs() {
+ QInquiry iq = QInquiry.inquiry;
+ QInquiryImage iqi = QInquiryImage.inquiryImage;
+ QInquiryAnswer iqa = QInquiryAnswer.inquiryAnswer;
+
+ return queryFactory
+ .selectFrom(iq)
+ .leftJoin(iq.inquiryImages, iqi).fetchJoin()
+ .leftJoin(iq.inquiryAnswer, iqa).fetchJoin()
+ .leftJoin(iqa.admin).fetchJoin()
+ .where(iq.deletedAt.isNull())
+ .orderBy(iq.createdAt.desc())
+ .distinct()
+ .fetch();
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java
new file mode 100644
index 00000000..40ea2901
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java
@@ -0,0 +1,78 @@
+package im.toduck.domain.inquiry.presentation.api;
+
+import java.util.Map;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest;
+import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation;
+import im.toduck.global.annotation.swagger.ApiResponseExplanations;
+import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation;
+import im.toduck.global.exception.ExceptionCode;
+import im.toduck.global.presentation.ApiResponse;
+import im.toduck.global.security.authentication.CustomUserDetails;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+
+@Tag(name = "InquiryAnswer")
+public interface InquiryAnswerApi {
+ @Operation(
+ summary = "문의 답변 생성",
+ description = "관리자가 문의 답변을 생성합니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "문의 답변 생성 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN),
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY),
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.ALREADY_ANSWERED_INQUIRY)
+ }
+ )
+ ResponseEntity>> createInquiryAnswer(
+ @RequestBody @Valid final InquiryAnswerCreateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ );
+
+ @Operation(
+ summary = "문의 답변 수정",
+ description = "관리자가 문의 답변을 수정합니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "문의 답변 수정 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN),
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY)
+ }
+ )
+ ResponseEntity>> updateInquiryAnswer(
+ @PathVariable final Long inquiryId,
+ @RequestBody @Valid final InquiryAnswerUpdateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ );
+
+ @Operation(
+ summary = "문의 답변 삭제",
+ description = "관리자가 문의 답변을 삭제합니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "문의 답변 삭제 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY),
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY_ANSWER)
+ }
+ )
+ ResponseEntity>> deleteInquiryAnswer(
+ @PathVariable final Long inquiryId
+ );
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java
new file mode 100644
index 00000000..41a1c5d7
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java
@@ -0,0 +1,111 @@
+package im.toduck.domain.inquiry.presentation.api;
+
+import java.util.Map;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse;
+import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation;
+import im.toduck.global.annotation.swagger.ApiResponseExplanations;
+import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation;
+import im.toduck.global.exception.ExceptionCode;
+import im.toduck.global.presentation.ApiResponse;
+import im.toduck.global.security.authentication.CustomUserDetails;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+
+@Tag(name = "Inquiry")
+public interface InquiryApi {
+ @Operation(
+ summary = "문의 목록 조회",
+ description = "사용자가 자신의 문의 목록을 조회합니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ responseClass = InquiryListResponse.class,
+ description = "문의 목록 조회 성공. 문의 목록을 반환합니다."
+ )
+ )
+ ResponseEntity> getInquiries(
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ );
+
+ @Operation(
+ summary = "문의 생성",
+ description = "사용자가 문의를 생성합니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "문의 생성 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN)
+ }
+ )
+ ResponseEntity>> createInquiry(
+ @RequestBody @Valid final InquiryCreateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ );
+
+ @Operation(
+ summary = "문의 수정",
+ description =
+ """
+ 문의를 수정합니다.
+ "inquiryId" : 문의 일련번호
+ "type" : 문의 타입 [ERROR, USAGE, SUGGESTION, ETC]
+ "inquiryImgs" : 문의 이미지 url
+ 수정하지 않을 경우 null을 입력하면 됩니다. 이미지의 경우 빈 리스트를 넣으면 이미지가 전부 삭제됩니다.
+ """
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "문의 수정 성공. 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY)
+ }
+ )
+ ResponseEntity>> updateInquiry(
+ @PathVariable final Long inquiryId,
+ @RequestBody @Valid final InquiryUpdateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ );
+
+ @Operation(
+ summary = "문의 삭제",
+ description = "문의를 삭제합니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ description = "문의 삭제 성공, 빈 content 객체를 반환합니다."
+ ),
+ errors = {
+ @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY)
+ }
+ )
+ ResponseEntity>> deleteInquiry(
+ @PathVariable final Long inquiryId,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ );
+
+ @Operation(
+ summary = "전체 문의 목록 조회",
+ description = "관리자가 전체 문의 목록을 조회합니다."
+ )
+ @ApiResponseExplanations(
+ success = @ApiSuccessResponseExplanation(
+ responseClass = InquiryListResponse.class,
+ description = "전체 문의 목록 조회 성공. 문의 목록을 반환합니다."
+ )
+ )
+ ResponseEntity> getAllInquiries(
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ );
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java
new file mode 100644
index 00000000..9b3c3503
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java
@@ -0,0 +1,67 @@
+package im.toduck.domain.inquiry.presentation.controller;
+
+import java.util.Map;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RestController;
+
+import im.toduck.domain.inquiry.domain.usecase.InquiryAnswerUseCase;
+import im.toduck.domain.inquiry.presentation.api.InquiryAnswerApi;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest;
+import im.toduck.global.presentation.ApiResponse;
+import im.toduck.global.security.authentication.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/inquiry-answers")
+public class InquiryAnswerController implements InquiryAnswerApi {
+
+ private final InquiryAnswerUseCase inquiryAnswerUseCase;
+
+ @Override
+ @PostMapping
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity>> createInquiryAnswer(
+ @RequestBody @Valid final InquiryAnswerCreateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ ) {
+ inquiryAnswerUseCase.createInquiryAnswer(request, userDetails.getUserId());
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+
+ @Override
+ @PatchMapping("/{inquiryId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity>> updateInquiryAnswer(
+ @PathVariable final Long inquiryId,
+ @RequestBody @Valid final InquiryAnswerUpdateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ ) {
+ inquiryAnswerUseCase.updateInquiryAnswer(inquiryId, request, userDetails.getUserId());
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+
+ @Override
+ @DeleteMapping("/{inquiryId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity>> deleteInquiryAnswer(
+ @PathVariable final Long inquiryId
+ ) {
+ inquiryAnswerUseCase.deleteInquiryAnswer(inquiryId);
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java
new file mode 100644
index 00000000..2dd96499
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java
@@ -0,0 +1,91 @@
+package im.toduck.domain.inquiry.presentation.controller;
+
+import java.util.Map;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+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.RestController;
+
+import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase;
+import im.toduck.domain.inquiry.presentation.api.InquiryApi;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse;
+import im.toduck.global.presentation.ApiResponse;
+import im.toduck.global.security.authentication.CustomUserDetails;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/inquiries")
+public class InquiryController implements InquiryApi {
+ private final InquiryUseCase inquiryUseCase;
+
+ @Override
+ @GetMapping("/me")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity> getInquiries(
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ ) {
+ InquiryListResponse response = inquiryUseCase.getInquiries(userDetails.getUserId());
+
+ return ResponseEntity.ok(ApiResponse.createSuccess(response));
+ }
+
+ @Override
+ @PostMapping
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity>> createInquiry(
+ @RequestBody @Valid final InquiryCreateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ ) {
+ inquiryUseCase.createInquiry(request, userDetails.getUserId());
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+
+ @Override
+ @PatchMapping("/{inquiryId}")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity>> updateInquiry(
+ @PathVariable final Long inquiryId,
+ @RequestBody @Valid final InquiryUpdateRequest request,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ ) {
+ inquiryUseCase.updateInquiry(inquiryId, request, userDetails.getUserId());
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+
+ @Override
+ @DeleteMapping("/{inquiryId}")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity>> deleteInquiry(
+ @PathVariable final Long inquiryId,
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ ) {
+ inquiryUseCase.deleteInquiry(inquiryId, userDetails.getUserId());
+
+ return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent());
+ }
+
+ @Override
+ @GetMapping("/admin")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> getAllInquiries(
+ @AuthenticationPrincipal final CustomUserDetails userDetails
+ ) {
+ InquiryListResponse response = inquiryUseCase.getAllInquiries(userDetails.getUserId());
+
+ return ResponseEntity.ok(ApiResponse.createSuccess(response));
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java
new file mode 100644
index 00000000..16aed97f
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java
@@ -0,0 +1,21 @@
+package im.toduck.domain.inquiry.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "문의 답변 생성 요청 DTO")
+public record InquiryAnswerCreateRequest(
+ @NotNull(message = "문의 ID는 비어있을 수 없습니다.")
+ @Schema(description = "문의 ID", example = "1")
+ Long inquiryId,
+
+ @NotBlank
+ @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.")
+ @Schema(description = "문의 답변", example = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!")
+ String content
+) {
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java
new file mode 100644
index 00000000..591b3613
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java
@@ -0,0 +1,16 @@
+package im.toduck.domain.inquiry.presentation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "문의 답변 수정 요청 DTO")
+public record InquiryAnswerUpdateRequest(
+ @NotBlank
+ @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.")
+ @Schema(description = "문의 답변", example = "도움이 되셨기를 바라며, 좋은 하루 되세요 :)")
+ String content
+) {
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java
new file mode 100644
index 00000000..e71d6b1e
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java
@@ -0,0 +1,27 @@
+package im.toduck.domain.inquiry.presentation.dto.request;
+
+import java.util.List;
+
+import im.toduck.domain.inquiry.persistence.entity.Type;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "사용자 문의 생성 요청 DTO")
+public record InquiryCreateRequest(
+ @NotNull(message = "문의 유형은 비어있을 수 없습니다.")
+ @Schema(description = "문의 유형", example = "ERROR")
+ Type type,
+
+ @Size(max = 500, message = "내용은 500자를 초과할 수 없습니다.")
+ @NotBlank(message = "내용은 비어있을 수 없습니다.")
+ @Schema(description = "문의 내용", example = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ")
+ String content,
+
+ @Schema(description = "문의 사진 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]")
+ List inquiryImgs
+) {
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java
new file mode 100644
index 00000000..a6f38e00
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java
@@ -0,0 +1,27 @@
+package im.toduck.domain.inquiry.presentation.dto.request;
+
+import java.util.List;
+
+import im.toduck.domain.inquiry.persistence.entity.Type;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "문의 수정 요청 DTO")
+public record InquiryUpdateRequest(
+ @NotNull(message = "문의 유형은 비어있을 수 없습니다.")
+ @Schema(description = "문의 유형", example = "USAGE")
+ Type type,
+
+ @Size(max = 500, message = "내용은 500자를 초과할 수 없습니다.")
+ @NotBlank(message = "내용은 비어있을 수 없습니다.")
+ @Schema(description = "문의 내용", example = "루틴을 매 달 반복되도록 설정할 수 있나요?")
+ String content,
+
+ @Schema(description = "변경된 이미지 URL 목록", example = "[\"https://cdn.app/image123.jpg\"]")
+ List inquiryImgs
+) {
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java
new file mode 100644
index 00000000..5fcf9efc
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java
@@ -0,0 +1,17 @@
+package im.toduck.domain.inquiry.presentation.dto.response;
+
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "문의 내역 목록 응답")
+public record InquiryListResponse(
+ @Schema(description = "문의 내역 목록")
+ List inquiryDtos
+) {
+ public static InquiryListResponse toListInquiryResponse(
+ final List inquiries
+ ) {
+ return new InquiryListResponse(inquiries);
+ }
+}
diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java
new file mode 100644
index 00000000..7bc1bc57
--- /dev/null
+++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java
@@ -0,0 +1,52 @@
+package im.toduck.domain.inquiry.presentation.dto.response;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
+
+import im.toduck.domain.inquiry.persistence.entity.Status;
+import im.toduck.domain.inquiry.persistence.entity.Type;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@Builder
+@Schema(description = "문의 내역 응답")
+public record InquiryResponse(
+ @Schema(description = "문의 ID", example = "1")
+ Long inquiryId,
+
+ @Schema(description = "유형", example = "이용 문의")
+ Type type,
+
+ @Schema(description = "내용", example = "문의 내용")
+ String content,
+
+ @Schema(description = "상태", example = "PENDING")
+ Status status,
+
+ @JsonSerialize(using = LocalDateTimeSerializer.class)
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
+ @Schema(description = "생성 날짜", example = "2026-01-22T14:30:00")
+ LocalDateTime createdAt,
+
+ @Schema(description = "문의 내역 이미지 url 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]")
+ List inquiryImgUrl,
+
+ @Schema(description = "문의 답변 ID", example = "1")
+ Long inquiryAnswerId,
+
+ @Schema(description = "답변 작성자 이름", example = "토덕 관리자")
+ String answerAdminName,
+
+ @Schema(description = "문의 내용", example = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!")
+ String answerContent,
+
+ @JsonSerialize(using = LocalDateTimeSerializer.class)
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
+ @Schema(description = "답변 생성 날짜", example = "2026-01-22T14:30:00")
+ LocalDateTime answerCreatedAt
+) {
+}
diff --git a/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java b/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java
index dfc391f6..dc4c176a 100644
--- a/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java
+++ b/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java
@@ -4,6 +4,8 @@
import org.springframework.transaction.annotation.Transactional;
+import im.toduck.domain.admin.domain.service.AdminService;
+import im.toduck.domain.admin.persistence.entity.Admin;
import im.toduck.domain.mypage.common.mapper.MyPageMapper;
import im.toduck.domain.mypage.domain.service.MyPageService;
import im.toduck.domain.mypage.presentation.dto.request.NickNameUpdateRequest;
@@ -16,6 +18,7 @@
import im.toduck.domain.user.domain.service.FollowService;
import im.toduck.domain.user.domain.service.UserService;
import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.domain.user.persistence.entity.UserRole;
import im.toduck.global.annotation.UseCase;
import im.toduck.global.exception.CommonException;
import im.toduck.global.exception.ExceptionCode;
@@ -32,6 +35,7 @@ public class MyPageUseCase {
private final MyPageService myPageService;
private final FollowService followService;
private final SocialBoardService socialBoardService;
+ private final AdminService adminService;
@Transactional
public void updateNickname(Long userId, NickNameUpdateRequest request) {
@@ -64,6 +68,10 @@ public void deleteAccount(Long userId, UserDeleteRequest request) {
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));
myPageService.recordUserDeletionLog(user, request);
+ if (user.getRole() == UserRole.ADMIN) {
+ Admin admin = adminService.getAdmin(user.getId());
+ adminService.deleteAdmin(admin);
+ }
this.deleteUserData(user);
}
diff --git a/src/main/java/im/toduck/domain/user/persistence/entity/User.java b/src/main/java/im/toduck/domain/user/persistence/entity/User.java
index 7598743a..95817682 100644
--- a/src/main/java/im/toduck/domain/user/persistence/entity/User.java
+++ b/src/main/java/im/toduck/domain/user/persistence/entity/User.java
@@ -121,4 +121,12 @@ public void unsuspend() {
this.suspendedUntil = null;
this.suspensionReason = null;
}
+
+ public void promoteToAdmin() {
+ this.role = UserRole.ADMIN;
+ }
+
+ public void demoteToUser() {
+ this.role = UserRole.USER;
+ }
}
diff --git a/src/main/java/im/toduck/global/exception/ExceptionCode.java b/src/main/java/im/toduck/global/exception/ExceptionCode.java
index f0a5c573..78b96d3d 100644
--- a/src/main/java/im/toduck/global/exception/ExceptionCode.java
+++ b/src/main/java/im/toduck/global/exception/ExceptionCode.java
@@ -124,6 +124,18 @@ public enum ExceptionCode {
NOT_FOUND_EVENTS_SOCIAL(HttpStatus.NOT_FOUND, 41203, "소셜 이벤트 참여 정보를 찾을 수 없습니다."),
INVALID_CONTENT_LENGTH(HttpStatus.BAD_REQUEST, 41204, "소셜 게시글 글자 수가 이벤트 참여 조건에 부합하지 않습니다."),
+ /* 420xx Inquiry */
+ NOT_FOUND_INQUIRY(HttpStatus.NOT_FOUND, 42001, "문의 정보를 찾을 수 없습니다."),
+ UNAUTHORIZED_ACCESS_INQUIRY(HttpStatus.FORBIDDEN, 42002, "문의에 접근 권한이 없습니다."),
+
+ /* 421xx InquiryAnswer */
+ NOT_FOUND_INQUIRY_ANSWER(HttpStatus.NOT_FOUND, 42101, "문의 답변 정보를 찾을 수 없습니다."),
+ ALREADY_ANSWERED_INQUIRY(HttpStatus.CONFLICT, 42102, "이미 문의 답변이 존재합니다."),
+
+ /* 422xx Admin */
+ NOT_FOUND_ADMIN(HttpStatus.NOT_FOUND, 42201, "관리자 정보를 찾을 수 없습니다."),
+ DUPLICATE_ADMIN(HttpStatus.CONFLICT, 42202, "이미 관리자 정보가 존재합니다."),
+
/* 431xx schedule */
NOT_FOUND_SCHEDULE_RECORD(HttpStatus.NOT_FOUND, 43101, "일정 기록을 찾을 수 없습니다.",
"일정 기록을 찾을 수 없을 때 발생하는 오류입니다."),
diff --git a/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java b/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java
new file mode 100644
index 00000000..dc8af1a8
--- /dev/null
+++ b/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java
@@ -0,0 +1,174 @@
+package im.toduck.domain.admin.presentation.controller;
+
+import static im.toduck.fixtures.user.UserFixtures.*;
+import static org.assertj.core.api.SoftAssertions.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.ServiceTest;
+import im.toduck.domain.admin.domain.usecase.AdminUseCase;
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.admin.persistence.repository.AdminRepository;
+import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest;
+import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest;
+import im.toduck.domain.admin.presentation.dto.response.AdminListResponse;
+import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
+import im.toduck.domain.user.persistence.entity.OAuthProvider;
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.domain.user.persistence.entity.UserRole;
+import im.toduck.domain.user.persistence.repository.UserRepository;
+
+class AdminControllerTest extends ServiceTest {
+
+ @Autowired
+ AdminUseCase adminUseCase;
+
+ @Autowired
+ AdminRepository adminRepository;
+
+ @Autowired
+ UserRepository userRepository;
+
+ @Transactional
+ @Nested
+ @DisplayName("관리자")
+ class AdminTest {
+ private User savedAdminUser, savedGeneralUser;
+
+ @BeforeEach
+ void setUp() {
+ savedAdminUser = userRepository.save(
+ User.builder()
+ .role(UserRole.ADMIN)
+ .nickname("admin")
+ .email("admin@naver.com")
+ .provider(OAuthProvider.APPLE)
+ .build()
+ );
+
+ savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER());
+ }
+
+ @Test
+ void 성공적으로_생성_조회한다() {
+ // given - when
+ AdminCreateRequest adminCreateRequest =
+ AdminCreateRequest.builder()
+ .userId(savedAdminUser.getId())
+ .displayName("토덕토덕")
+ .build();
+
+ AdminCreateRequest adminCreateRequest2 =
+ AdminCreateRequest.builder()
+ .userId(savedGeneralUser.getId())
+ .displayName("토닥토닥")
+ .build();
+
+ Admin admin = adminUseCase.createAdmin(adminCreateRequest);
+ Admin admin2 = adminUseCase.createAdmin(adminCreateRequest2);
+
+ // then
+ AdminResponse adminResponse = adminUseCase.getAdmin(savedAdminUser.getId());
+ AdminResponse adminResponse2 = adminUseCase.getAdmin(savedGeneralUser.getId());
+ AdminListResponse adminListResponse = adminUseCase.getAdmins();
+
+ assertSoftly(softly -> {
+ softly.assertThat(adminResponse.userId()).isEqualTo(admin.getUser().getId());
+ softly.assertThat(adminResponse.displayName()).isEqualTo(admin.getDisplayName());
+ });
+
+ assertSoftly(softly -> {
+ softly.assertThat(adminResponse2.userId()).isEqualTo(admin2.getUser().getId());
+ softly.assertThat(adminResponse2.displayName()).isEqualTo(admin2.getDisplayName());
+ softly.assertThat(savedGeneralUser.getRole()).isEqualTo(UserRole.ADMIN);
+ });
+
+ assertSoftly(softly -> {
+ softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(2);
+ softly.assertThat(adminListResponse.adminDtos().get(0).displayName()).isEqualTo(admin.getDisplayName());
+ softly.assertThat(adminListResponse.adminDtos().get(1).displayName())
+ .isEqualTo(admin2.getDisplayName());
+ });
+ }
+
+ @Test
+ void 성공적으로_수정한다() {
+ // given
+ AdminCreateRequest adminCreateRequest =
+ AdminCreateRequest.builder()
+ .userId(savedAdminUser.getId())
+ .displayName("토덕토덕")
+ .build();
+
+ Admin admin = adminUseCase.createAdmin(adminCreateRequest);
+
+ // when
+ AdminUpdateRequest adminUpdateRequest =
+ AdminUpdateRequest.builder()
+ .displayName("토닥토닥")
+ .build();
+
+ adminUseCase.updateAdmin(savedAdminUser.getId(), adminUpdateRequest);
+
+ // then
+ assertSoftly(softly -> {
+ softly.assertThat(admin.getDisplayName()).isEqualTo("토닥토닥");
+ });
+ }
+
+ @Test
+ void 성공적으로_삭제한다() {
+ // given
+ AdminCreateRequest adminCreateRequest =
+ AdminCreateRequest.builder()
+ .userId(savedAdminUser.getId())
+ .displayName("토덕토덕")
+ .build();
+
+ Admin admin = adminUseCase.createAdmin(adminCreateRequest);
+
+ // when
+ adminUseCase.deleteAdmin(admin.getUser().getId());
+ AdminListResponse adminListResponse = adminUseCase.getAdmins();
+
+ // then
+ assertSoftly(softly -> {
+ softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(0);
+ softly.assertThat(savedAdminUser.getRole()).isEqualTo(UserRole.USER);
+ });
+ }
+
+ @Test
+ void 삭제되지_않은_관리자만_조회한다() {
+ // given - when
+ AdminCreateRequest adminCreateRequest =
+ AdminCreateRequest.builder()
+ .userId(savedAdminUser.getId())
+ .displayName("토덕토덕")
+ .build();
+
+ AdminCreateRequest adminCreateRequest2 =
+ AdminCreateRequest.builder()
+ .userId(savedGeneralUser.getId())
+ .displayName("토닥토닥")
+ .build();
+
+ Admin admin = adminUseCase.createAdmin(adminCreateRequest);
+ Admin admin2 = adminUseCase.createAdmin(adminCreateRequest2);
+
+ adminUseCase.deleteAdmin(admin2.getUser().getId());
+
+ // then
+ AdminListResponse adminListResponse = adminUseCase.getAdmins();
+
+ assertSoftly(softly -> {
+ softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(1);
+ });
+ }
+ }
+}
diff --git a/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java b/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java
index 37058e8f..a969231b 100644
--- a/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java
+++ b/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java
@@ -116,15 +116,6 @@ void setUp() {
.eventsDetailImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
.build();
- EventsDetailCreateRequest eventsDetailCreateRequest2 =
- EventsDetailCreateRequest.builder()
- .eventsId(savedEvent.getId())
- .routingUrl("toduck://anotherPost")
- .buttonVisible(true)
- .buttonText("당첨 확인하기")
- .eventsDetailImgs(Arrays.asList("xyz", "123"))
- .build();
-
EventsDetail eventsDetail1 = eventsDetailUseCase.createEventsDetail(eventsDetailCreateRequest,
savedAdminUser.getId());
diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java
new file mode 100644
index 00000000..91d16cd2
--- /dev/null
+++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java
@@ -0,0 +1,364 @@
+package im.toduck.domain.inquiry.presentation.controller;
+
+import static im.toduck.fixtures.user.UserFixtures.*;
+import static org.assertj.core.api.AssertionsForClassTypes.*;
+import static org.assertj.core.api.SoftAssertions.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.ServiceTest;
+import im.toduck.domain.admin.domain.usecase.AdminUseCase;
+import im.toduck.domain.admin.persistence.entity.Admin;
+import im.toduck.domain.admin.persistence.repository.AdminRepository;
+import im.toduck.domain.inquiry.domain.usecase.InquiryAnswerUseCase;
+import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase;
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer;
+import im.toduck.domain.inquiry.persistence.entity.Status;
+import im.toduck.domain.inquiry.persistence.entity.Type;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse;
+import im.toduck.domain.user.persistence.entity.OAuthProvider;
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.domain.user.persistence.entity.UserRole;
+import im.toduck.domain.user.persistence.repository.UserRepository;
+import im.toduck.global.exception.CommonException;
+import im.toduck.global.exception.ExceptionCode;
+
+class InquiryAnswerControllerTest extends ServiceTest {
+
+ @Autowired
+ InquiryUseCase inquiryUseCase;
+
+ @Autowired
+ InquiryAnswerUseCase inquiryAnswerUseCase;
+
+ @Autowired
+ AdminUseCase adminUseCase;
+
+ @Autowired
+ UserRepository userRepository;
+
+ @Autowired
+ AdminRepository adminRepository;
+
+ @Transactional
+ @Nested
+ @DisplayName("문의 답변")
+ class InquiryAnswerTest {
+ private User savedAdminUser, savedGeneralUser;
+
+ String content = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ";
+ String content2 = "루틴을 매 달 반복되도록 설정할 수 있나요?";
+
+ String answer = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!";
+ String answer2 = "도움이 되셨기를 바라며, 좋은 하루 되세요 :)";
+
+ InquiryCreateRequest inquiryCreateRequest =
+ InquiryCreateRequest.builder()
+ .type(Type.ERROR)
+ .content(content)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ InquiryCreateRequest inquiryCreateRequest2 =
+ InquiryCreateRequest.builder()
+ .type(Type.USAGE)
+ .content(content2)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ @BeforeEach
+ void setUp() {
+ savedAdminUser = userRepository.save(
+ User.builder()
+ .role(UserRole.ADMIN)
+ .nickname("admin")
+ .email("admin@naver.com")
+ .provider(OAuthProvider.APPLE)
+ .build()
+ );
+
+ savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER());
+ }
+
+ @Transactional
+ @Test
+ void 성공적으로_생성_조회한다() {
+ // given
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+ Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest2, savedGeneralUser.getId());
+
+ Admin admin = adminRepository.save(
+ Admin.builder()
+ .user(savedAdminUser)
+ .displayName("토덕 관리자")
+ .build()
+ );
+
+ // when
+ InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder()
+ .inquiryId(inquiry.getId())
+ .content(answer)
+ .build();
+
+ InquiryAnswerCreateRequest request2 = InquiryAnswerCreateRequest.builder()
+ .inquiryId(inquiry2.getId())
+ .content(answer2)
+ .build();
+
+ InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request,
+ admin.getUser().getId());
+
+ InquiryAnswer inquiryAnswer2 = inquiryAnswerUseCase.createInquiryAnswer(request2,
+ admin.getUser().getId());
+
+ // then
+ InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId());
+
+ assertSoftly(softly -> {
+ softly.assertThat(response.inquiryDtos().size()).isEqualTo(2L);
+ softly.assertThat(response.inquiryDtos().get(1).type())
+ .isEqualTo(inquiryCreateRequest.type());
+ softly.assertThat(response.inquiryDtos().get(1).content()).isEqualTo(content);
+ softly.assertThat(response.inquiryDtos().get(1).status()).isEqualTo(Status.ANSWERED);
+ softly.assertThat(response.inquiryDtos().get(1).inquiryAnswerId())
+ .isEqualTo(inquiryAnswer.getId());
+ softly.assertThat(response.inquiryDtos().get(1).answerAdminName())
+ .isEqualTo(admin.getDisplayName());
+ softly.assertThat(response.inquiryDtos().get(1).answerContent()).isEqualTo(answer);
+ softly.assertThat(response.inquiryDtos().get(1).answerCreatedAt())
+ .isEqualTo(inquiryAnswer.getCreatedAt());
+
+ softly.assertThat(response.inquiryDtos().get(0).type())
+ .isEqualTo(inquiryCreateRequest2.type());
+ softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content2);
+ softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED);
+ softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId())
+ .isEqualTo(inquiryAnswer2.getId());
+ softly.assertThat(response.inquiryDtos().get(0).answerAdminName())
+ .isEqualTo(admin.getDisplayName());
+ softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer2);
+ softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt())
+ .isEqualTo(inquiryAnswer2.getCreatedAt());
+ });
+ }
+
+ @Transactional
+ @Test
+ void 이미_답변이_달린_경우_에러가_발생한다() {
+ // given
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ Admin admin = adminRepository.save(
+ Admin.builder()
+ .user(savedAdminUser)
+ .displayName("토덕 관리자")
+ .build()
+ );
+
+ InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder()
+ .inquiryId(inquiry.getId())
+ .content(answer)
+ .build();
+
+ InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request,
+ admin.getUser().getId());
+
+ // when - then
+ assertThatThrownBy(() ->
+ inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId())
+ )
+ .isInstanceOfSatisfying(CommonException.class, e ->
+ assertThat(e.getErrorCode())
+ .isEqualTo(ExceptionCode.ALREADY_ANSWERED_INQUIRY.getErrorCode())
+ );
+ }
+
+ @Transactional
+ @Test
+ void 답변_작성자의_관리자_권한이_제거된_경우에도_답변_작성자가_표시된다() {
+ // given
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ Admin admin = adminRepository.save(
+ Admin.builder()
+ .user(savedAdminUser)
+ .displayName("토덕 관리자")
+ .build()
+ );
+
+ InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder()
+ .inquiryId(inquiry.getId())
+ .content(answer)
+ .build();
+
+ InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request,
+ admin.getUser().getId());
+
+ // when
+ adminUseCase.deleteAdmin(admin.getUser().getId());
+
+ // then
+ InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId());
+
+ assertSoftly(softly -> {
+ softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L);
+ softly.assertThat(response.inquiryDtos().get(0).type())
+ .isEqualTo(inquiryCreateRequest.type());
+ softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content);
+ softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED);
+ softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId())
+ .isEqualTo(inquiryAnswer.getId());
+ softly.assertThat(response.inquiryDtos().get(0).answerAdminName())
+ .isEqualTo(admin.getDisplayName());
+ softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer);
+ softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt())
+ .isEqualTo(inquiryAnswer.getCreatedAt());
+
+ softly.assertThat(savedAdminUser.getRole()).isEqualTo(UserRole.USER);
+ });
+ }
+
+ @Transactional
+ @Test
+ void 성공적으로_답변을_수정한다() {
+ // given
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ Admin admin = adminRepository.save(
+ Admin.builder()
+ .user(savedAdminUser)
+ .displayName("토덕 관리자")
+ .build()
+ );
+
+ InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder()
+ .inquiryId(inquiry.getId())
+ .content(answer)
+ .build();
+
+ InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request,
+ admin.getUser().getId());
+
+ // when
+ InquiryAnswerUpdateRequest updateRequest = InquiryAnswerUpdateRequest.builder()
+ .content(answer2)
+ .build();
+
+ inquiryAnswerUseCase.updateInquiryAnswer(inquiry.getId(), updateRequest, admin.getUser().getId());
+
+ // then
+ InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId());
+
+ assertSoftly(softly -> {
+ softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L);
+ softly.assertThat(response.inquiryDtos().get(0).type())
+ .isEqualTo(inquiryCreateRequest.type());
+ softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content);
+ softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED);
+ softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId())
+ .isEqualTo(inquiryAnswer.getId());
+ softly.assertThat(response.inquiryDtos().get(0).answerAdminName())
+ .isEqualTo(admin.getDisplayName());
+ softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer2);
+ softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt())
+ .isEqualTo(inquiryAnswer.getCreatedAt());
+ softly.assertThat(inquiry.getInquiryAnswer().getUpdatedAt()).isNotNull();
+ });
+ }
+
+ @Transactional
+ @Test
+ void 성공적으로_답변을_삭제한다() {
+ // given
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ Admin admin = adminRepository.save(
+ Admin.builder()
+ .user(savedAdminUser)
+ .displayName("토덕 관리자")
+ .build()
+ );
+
+ InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder()
+ .inquiryId(inquiry.getId())
+ .content(answer)
+ .build();
+
+ InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request,
+ admin.getUser().getId());
+
+ // when
+ inquiryAnswerUseCase.deleteInquiryAnswer(inquiry.getId());
+
+ // then
+ InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId());
+
+ assertSoftly(softly -> {
+ softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L);
+ softly.assertThat(response.inquiryDtos().get(0).type())
+ .isEqualTo(inquiryCreateRequest.type());
+ softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content);
+ softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING);
+ softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()).isNull();
+ softly.assertThat(response.inquiryDtos().get(0).answerAdminName()).isNull();
+ softly.assertThat(response.inquiryDtos().get(0).answerContent()).isNull();
+ softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()).isNull();
+ });
+ }
+
+ @Transactional
+ @Test
+ void 성공적으로_답변_삭제_후_다시_생성한다() {
+ // given
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ Admin admin = adminRepository.save(
+ Admin.builder()
+ .user(savedAdminUser)
+ .displayName("토덕 관리자")
+ .build()
+ );
+
+ InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder()
+ .inquiryId(inquiry.getId())
+ .content(answer)
+ .build();
+
+ InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request,
+ admin.getUser().getId());
+
+ inquiryAnswerUseCase.deleteInquiryAnswer(inquiry.getId());
+
+ // when
+ InquiryAnswer inquiryAnswer2 = inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId());
+
+ // then
+ InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId());
+
+ assertSoftly(softly -> {
+ softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L);
+ softly.assertThat(response.inquiryDtos().get(0).type())
+ .isEqualTo(inquiryCreateRequest.type());
+ softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content);
+ softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED);
+ softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()).isEqualTo(inquiryAnswer2.getId());
+ softly.assertThat(response.inquiryDtos().get(0).answerAdminName()).isEqualTo(admin.getDisplayName());
+ softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(request.content());
+ softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt())
+ .isEqualTo(inquiryAnswer2.getCreatedAt());
+ softly.assertThat(inquiryAnswer2.getDeletedAt()).isNull();
+ });
+ }
+ }
+}
diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java
new file mode 100644
index 00000000..a17c41e3
--- /dev/null
+++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java
@@ -0,0 +1,210 @@
+package im.toduck.domain.inquiry.presentation.controller;
+
+import static im.toduck.fixtures.user.UserFixtures.*;
+import static org.assertj.core.api.AssertionsForClassTypes.*;
+import static org.assertj.core.api.SoftAssertions.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+
+import im.toduck.ServiceTest;
+import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase;
+import im.toduck.domain.inquiry.persistence.entity.Inquiry;
+import im.toduck.domain.inquiry.persistence.entity.Status;
+import im.toduck.domain.inquiry.persistence.entity.Type;
+import im.toduck.domain.inquiry.persistence.repository.InquiryRepository;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest;
+import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest;
+import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse;
+import im.toduck.domain.user.persistence.entity.OAuthProvider;
+import im.toduck.domain.user.persistence.entity.User;
+import im.toduck.domain.user.persistence.entity.UserRole;
+import im.toduck.domain.user.persistence.repository.UserRepository;
+import im.toduck.global.exception.CommonException;
+import im.toduck.global.exception.ExceptionCode;
+
+class InquiryControllerTest extends ServiceTest {
+
+ @Autowired
+ InquiryUseCase inquiryUseCase;
+
+ @Autowired
+ InquiryRepository inquiryRepository;
+
+ @Autowired
+ UserRepository userRepository;
+
+ @Transactional
+ @Nested
+ @DisplayName("문의")
+ class InquiryTest {
+ private User savedAdminUser, savedGeneralUser;
+
+ String content = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ";
+ String content2 = "루틴을 매 달 반복되도록 설정할 수 있나요?";
+
+ @BeforeEach
+ void setUp() {
+ savedAdminUser = userRepository.save(
+ User.builder()
+ .role(UserRole.ADMIN)
+ .nickname("admin")
+ .email("admin@naver.com")
+ .provider(OAuthProvider.APPLE)
+ .build()
+ );
+
+ savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER());
+ }
+
+ @Test
+ void 성공적으로_생성한다() {
+ // given - when
+ InquiryCreateRequest inquiryCreateRequest =
+ InquiryCreateRequest.builder()
+ .type(Type.ERROR)
+ .content(content)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ // then
+ Inquiry inquiry2 = inquiryRepository.findById(inquiry.getId())
+ .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY));
+
+ assertSoftly(softly -> {
+ softly.assertThat(inquiry2.getUser().getId())
+ .isEqualTo(inquiry.getUser().getId());
+ softly.assertThat((inquiry2.getType()))
+ .isEqualTo(inquiry.getType());
+ softly.assertThat(inquiry2.getContent())
+ .isEqualTo(inquiry.getContent());
+ softly.assertThat(inquiry2.getStatus())
+ .isEqualTo(inquiry.getStatus());
+ softly.assertThat(inquiry2.getCreatedAt())
+ .isEqualTo(inquiry.getCreatedAt());
+ });
+ }
+
+ @Test
+ void 성공적으로_사용자가_자신의_문의를_조회한다() {
+ // given
+ InquiryCreateRequest inquiryCreateRequest =
+ InquiryCreateRequest.builder()
+ .type(Type.ERROR)
+ .content(content)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+ Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest, savedAdminUser.getId());
+
+ // when
+ InquiryListResponse inquiryListResponse = inquiryUseCase.getInquiries(savedGeneralUser.getId());
+
+ // then
+ assertSoftly(softly -> {
+ softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(1);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.ERROR);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING);
+ });
+ }
+
+ @Test
+ void 성공적으로_문의를_수정한다() {
+ // given
+ InquiryCreateRequest inquiryCreateRequest =
+ InquiryCreateRequest.builder()
+ .type(Type.ERROR)
+ .content(content)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ // when
+ InquiryUpdateRequest inquiryUpdateRequest =
+ InquiryUpdateRequest.builder()
+ .type(Type.USAGE)
+ .content(content2)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ Inquiry inquiry2 = inquiryUseCase.updateInquiry(inquiry.getId(), inquiryUpdateRequest,
+ savedGeneralUser.getId());
+
+ InquiryListResponse inquiryListResponse = inquiryUseCase.getInquiries(savedGeneralUser.getId());
+
+ // then
+ assertSoftly(softly -> {
+ softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(1);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.USAGE);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content2);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING);
+ });
+ }
+
+ @Test
+ void 성공적으로_삭제한다() {
+ // given
+ InquiryCreateRequest inquiryCreateRequest =
+ InquiryCreateRequest.builder()
+ .type(Type.ERROR)
+ .content(content)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+
+ // when
+ inquiryUseCase.deleteInquiry(inquiry.getId(), savedGeneralUser.getId());
+
+ // then
+ assertThat(inquiryRepository.findById(inquiry.getId())).isNotPresent();
+ }
+
+ @Test
+ void 성공적으로_관리자가_모든_문의를_조회한다() throws Exception {
+ // given
+ InquiryCreateRequest inquiryCreateRequest =
+ InquiryCreateRequest.builder()
+ .type(Type.ERROR)
+ .content(content)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ InquiryCreateRequest inquiryCreateRequest2 =
+ InquiryCreateRequest.builder()
+ .type(Type.USAGE)
+ .content(content2)
+ .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ"))
+ .build();
+
+ Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId());
+ Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest2, savedAdminUser.getId());
+
+ // when
+ InquiryListResponse inquiryListResponse = inquiryUseCase.getAllInquiries(savedAdminUser.getId());
+
+ // then
+ assertSoftly(softly -> {
+ softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(2);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(1).type()).isEqualTo(Type.ERROR);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(1).content()).isEqualTo(content);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(1).status()).isEqualTo(Status.PENDING);
+
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.USAGE);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content2);
+ softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING);
+ });
+ }
+ }
+}