diff --git a/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaEntity.java b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaEntity.java new file mode 100644 index 000000000..87d6f39c3 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaEntity.java @@ -0,0 +1,45 @@ +package com.process.clash.adapter.persistence.helpcontent; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Entity +@EntityListeners(AuditingEntityListener.class) +@Table(name = "help_contents") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class HelpContentJpaEntity { + + @Id + @Column(nullable = false, updatable = false) + private String key; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Version + @Column(nullable = false) + private Long version; + + @CreatedDate + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaMapper.java b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaMapper.java new file mode 100644 index 000000000..a0f93be6c --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaMapper.java @@ -0,0 +1,28 @@ +package com.process.clash.adapter.persistence.helpcontent; + +import com.process.clash.domain.helpcontent.entity.HelpContent; +import org.springframework.stereotype.Component; + +@Component +public class HelpContentJpaMapper { + + public HelpContent toDomain(HelpContentJpaEntity entity) { + return new HelpContent( + entity.getKey(), + entity.getContent(), + entity.getVersion(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + public HelpContentJpaEntity toJpaEntity(HelpContent domain) { + return new HelpContentJpaEntity( + domain.key(), + domain.content(), + domain.version(), + domain.createdAt(), + domain.updatedAt() + ); + } +} diff --git a/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaRepository.java new file mode 100644 index 000000000..b8710589b --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentJpaRepository.java @@ -0,0 +1,6 @@ +package com.process.clash.adapter.persistence.helpcontent; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HelpContentJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentPersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentPersistenceAdapter.java new file mode 100644 index 000000000..67210dde2 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/helpcontent/HelpContentPersistenceAdapter.java @@ -0,0 +1,33 @@ +package com.process.clash.adapter.persistence.helpcontent; + +import com.process.clash.application.helpcontent.port.out.HelpContentRepositoryPort; +import com.process.clash.domain.helpcontent.entity.HelpContent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class HelpContentPersistenceAdapter implements HelpContentRepositoryPort { + + private final HelpContentJpaRepository helpContentJpaRepository; + private final HelpContentJpaMapper helpContentJpaMapper; + + @Override + public Optional findByKey(String key) { + return helpContentJpaRepository.findById(key).map(helpContentJpaMapper::toDomain); + } + + @Override + public boolean existsByKey(String key) { + return helpContentJpaRepository.existsById(key); + } + + @Override + public HelpContent save(HelpContent helpContent) { + HelpContentJpaEntity entity = helpContentJpaMapper.toJpaEntity(helpContent); + HelpContentJpaEntity saved = helpContentJpaRepository.saveAndFlush(entity); + return helpContentJpaMapper.toDomain(saved); + } +} diff --git a/src/main/java/com/process/clash/adapter/web/helpcontent/admin/controller/AdminHelpContentController.java b/src/main/java/com/process/clash/adapter/web/helpcontent/admin/controller/AdminHelpContentController.java new file mode 100644 index 000000000..4aa162b1a --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/helpcontent/admin/controller/AdminHelpContentController.java @@ -0,0 +1,56 @@ +package com.process.clash.adapter.web.helpcontent.admin.controller; + +import com.process.clash.adapter.web.common.ApiResponse; +import com.process.clash.adapter.web.helpcontent.docs.controller.AdminHelpContentControllerDocument; +import com.process.clash.adapter.web.helpcontent.dto.CreateHelpContentDto; +import com.process.clash.adapter.web.helpcontent.dto.UpdateHelpContentDto; +import com.process.clash.adapter.web.security.AuthenticatedActor; +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.helpcontent.data.CreateHelpContentData; +import com.process.clash.application.helpcontent.data.UpdateHelpContentData; +import com.process.clash.application.helpcontent.port.in.CreateHelpContentUseCase; +import com.process.clash.application.helpcontent.port.in.UpdateHelpContentUseCase; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/help-contents") +@RequiredArgsConstructor +public class AdminHelpContentController implements AdminHelpContentControllerDocument { + + private final CreateHelpContentUseCase createHelpContentUseCase; + private final UpdateHelpContentUseCase updateHelpContentUseCase; + + @Override + @PostMapping + public ApiResponse createHelpContent( + @AuthenticatedActor Actor actor, + @Valid @RequestBody CreateHelpContentDto.Request request + ) { + CreateHelpContentData.Result result = createHelpContentUseCase.execute(request.toCommand(actor)); + return ApiResponse.created( + CreateHelpContentDto.Response.from(result), + "도움말 내용이 성공적으로 생성되었습니다." + ); + } + + @Override + @PutMapping("/{key}") + public ApiResponse updateHelpContent( + @AuthenticatedActor Actor actor, + @PathVariable String key, + @Valid @RequestBody UpdateHelpContentDto.Request request + ) { + UpdateHelpContentData.Result result = updateHelpContentUseCase.execute(request.toCommand(actor, key)); + return ApiResponse.success( + UpdateHelpContentDto.Response.from(result), + "도움말 내용이 성공적으로 수정되었습니다." + ); + } +} diff --git a/src/main/java/com/process/clash/adapter/web/helpcontent/controller/HelpContentController.java b/src/main/java/com/process/clash/adapter/web/helpcontent/controller/HelpContentController.java new file mode 100644 index 000000000..186e225a4 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/helpcontent/controller/HelpContentController.java @@ -0,0 +1,44 @@ +package com.process.clash.adapter.web.helpcontent.controller; + +import com.process.clash.adapter.web.helpcontent.docs.controller.HelpContentControllerDocument; +import com.process.clash.application.helpcontent.data.GetHelpContentData; +import com.process.clash.application.helpcontent.port.in.GetHelpContentUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.WebRequest; + +import java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/api/help-contents") +@RequiredArgsConstructor +public class HelpContentController implements HelpContentControllerDocument { + + private final GetHelpContentUseCase getHelpContentUseCase; + + @Override + @GetMapping("/{key}") + public ResponseEntity getHelpContent( + @PathVariable String key, + WebRequest request + ) { + GetHelpContentData.Result result = getHelpContentUseCase.execute(key); + String etag = "\"" + result.version() + "\""; + + if (request.checkNotModified(etag)) { + return null; + } + + return ResponseEntity.ok() + .contentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8)) + .cacheControl(CacheControl.noCache()) + .eTag(etag) + .body(result.content()); + } +} diff --git a/src/main/java/com/process/clash/adapter/web/helpcontent/docs/controller/AdminHelpContentControllerDocument.java b/src/main/java/com/process/clash/adapter/web/helpcontent/docs/controller/AdminHelpContentControllerDocument.java new file mode 100644 index 000000000..aa3b20ff2 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/helpcontent/docs/controller/AdminHelpContentControllerDocument.java @@ -0,0 +1,69 @@ +package com.process.clash.adapter.web.helpcontent.docs.controller; + +import com.process.clash.adapter.web.common.ApiResponse; +import com.process.clash.adapter.web.helpcontent.dto.CreateHelpContentDto; +import com.process.clash.adapter.web.helpcontent.dto.UpdateHelpContentDto; +import com.process.clash.application.common.actor.Actor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "어드민 도움말 API", description = "클라이언트 도움말 수정") +public interface AdminHelpContentControllerDocument { + + @Operation(summary = "도움말 생성", description = "새 도움말 키와 전체 텍스트를 생성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "생성 성공", + content = @Content( + schema = @Schema(implementation = CreateHelpContentDto.Response.class), + examples = @ExampleObject(value = """ + { + "success": true, + "message": "도움말 내용이 성공적으로 생성되었습니다.", + "data": { + "key": "new-tooltip", + "content": "새 안내 문구입니다.", + "version": 0, + "createdAt": "2026-07-14T14:00:00Z" + } + } + """) + )), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 존재하는 도움말 키") + }) + ApiResponse createHelpContent( + @Parameter(hidden = true) Actor actor, + CreateHelpContentDto.Request request + ); + + @Operation(summary = "도움말 수정", description = "도움말 전체 텍스트를 수정하고 ETag 버전을 증가시킵니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content( + schema = @Schema(implementation = UpdateHelpContentDto.Response.class), + examples = @ExampleObject(value = """ + { + "success": true, + "message": "도움말 내용이 성공적으로 수정되었습니다.", + "data": { + "key": "cookie-tooltip", + "content": "쿠키는 상점에서 사용 할 수 있어요!", + "version": 2, + "updatedAt": "2026-07-14T14:00:00Z" + } + } + """) + )), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "도움말을 찾을 수 없음") + }) + ApiResponse updateHelpContent( + @Parameter(hidden = true) Actor actor, + @Parameter(description = "수정할 도움말 키", example = "cookie-tooltip", required = true) @PathVariable String key, + UpdateHelpContentDto.Request request + ); +} diff --git a/src/main/java/com/process/clash/adapter/web/helpcontent/docs/controller/HelpContentControllerDocument.java b/src/main/java/com/process/clash/adapter/web/helpcontent/docs/controller/HelpContentControllerDocument.java new file mode 100644 index 000000000..a9d64a7a5 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/helpcontent/docs/controller/HelpContentControllerDocument.java @@ -0,0 +1,28 @@ +package com.process.clash.adapter.web.helpcontent.docs.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.context.request.WebRequest; + +@Tag(name = "도움말 API", description = "클라이언트 도움말 조회") +public interface HelpContentControllerDocument { + + @Operation(summary = "도움말 조회", description = "If-None-Match 헤더가 현재 ETag와 같으면 304를, 다르면 도움말 전체 텍스트와 ETag를 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content( + mediaType = "text/plain", + schema = @Schema(type = "string"), + examples = @ExampleObject(value = "쿠키는 상점에서 사용 할 수 있어요!\\n\\n로드맵 챕터 클리어: 100 쿠키") + )), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "304", description = "변경 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "도움말을 찾을 수 없음") + }) + ResponseEntity getHelpContent(@PathVariable String key, WebRequest request); +} diff --git a/src/main/java/com/process/clash/adapter/web/helpcontent/dto/CreateHelpContentDto.java b/src/main/java/com/process/clash/adapter/web/helpcontent/dto/CreateHelpContentDto.java new file mode 100644 index 000000000..8f1404629 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/helpcontent/dto/CreateHelpContentDto.java @@ -0,0 +1,33 @@ +package com.process.clash.adapter.web.helpcontent.dto; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.helpcontent.data.CreateHelpContentData; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +import java.time.Instant; + +public class CreateHelpContentDto { + + @Schema(name = "CreateHelpContentDtoRequest") + public record Request( + @NotBlank(message = "도움말 키는 비워둘 수 없습니다.") + @Pattern(regexp = "[a-z0-9-]+", message = "도움말 키는 영문 소문자, 숫자, 하이픈만 사용할 수 있습니다.") + String key, + + @NotBlank(message = "도움말 내용은 비워둘 수 없습니다.") + String content + ) { + public CreateHelpContentData.Command toCommand(Actor actor) { + return new CreateHelpContentData.Command(actor, key, content); + } + } + + @Schema(name = "CreateHelpContentDtoResponse") + public record Response(String key, String content, long version, Instant createdAt) { + public static Response from(CreateHelpContentData.Result result) { + return new Response(result.key(), result.content(), result.version(), result.createdAt()); + } + } +} diff --git a/src/main/java/com/process/clash/adapter/web/helpcontent/dto/UpdateHelpContentDto.java b/src/main/java/com/process/clash/adapter/web/helpcontent/dto/UpdateHelpContentDto.java new file mode 100644 index 000000000..b8d0067ec --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/helpcontent/dto/UpdateHelpContentDto.java @@ -0,0 +1,28 @@ +package com.process.clash.adapter.web.helpcontent.dto; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.helpcontent.data.UpdateHelpContentData; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +import java.time.Instant; + +public class UpdateHelpContentDto { + + @Schema(name = "UpdateHelpContentDtoRequest") + public record Request( + @NotBlank(message = "도움말 내용은 비워둘 수 없습니다.") + String content + ) { + public UpdateHelpContentData.Command toCommand(Actor actor, String key) { + return new UpdateHelpContentData.Command(actor, key, content); + } + } + + @Schema(name = "UpdateHelpContentDtoResponse") + public record Response(String key, String content, long version, Instant updatedAt) { + public static Response from(UpdateHelpContentData.Result result) { + return new Response(result.key(), result.content(), result.version(), result.updatedAt()); + } + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/data/CreateHelpContentData.java b/src/main/java/com/process/clash/application/helpcontent/data/CreateHelpContentData.java new file mode 100644 index 000000000..9caf1ecac --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/data/CreateHelpContentData.java @@ -0,0 +1,26 @@ +package com.process.clash.application.helpcontent.data; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.domain.helpcontent.entity.HelpContent; + +import java.time.Instant; + +public class CreateHelpContentData { + + public record Command(Actor actor, String key, String content) { + public HelpContent toDomain() { + return new HelpContent(key, content, null, null, null); + } + } + + public record Result(String key, String content, long version, Instant createdAt) { + public static Result from(HelpContent helpContent) { + return new Result( + helpContent.key(), + helpContent.content(), + helpContent.version(), + helpContent.createdAt() + ); + } + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/data/GetHelpContentData.java b/src/main/java/com/process/clash/application/helpcontent/data/GetHelpContentData.java new file mode 100644 index 000000000..57083f00b --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/data/GetHelpContentData.java @@ -0,0 +1,12 @@ +package com.process.clash.application.helpcontent.data; + +import com.process.clash.domain.helpcontent.entity.HelpContent; + +public class GetHelpContentData { + + public record Result(String key, String content, long version) { + public static Result from(HelpContent helpContent) { + return new Result(helpContent.key(), helpContent.content(), helpContent.version()); + } + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/data/UpdateHelpContentData.java b/src/main/java/com/process/clash/application/helpcontent/data/UpdateHelpContentData.java new file mode 100644 index 000000000..da69054c0 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/data/UpdateHelpContentData.java @@ -0,0 +1,23 @@ +package com.process.clash.application.helpcontent.data; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.domain.helpcontent.entity.HelpContent; + +import java.time.Instant; + +public class UpdateHelpContentData { + + public record Command(Actor actor, String key, String content) { + } + + public record Result(String key, String content, long version, Instant updatedAt) { + public static Result from(HelpContent helpContent) { + return new Result( + helpContent.key(), + helpContent.content(), + helpContent.version(), + helpContent.updatedAt() + ); + } + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/exception/exception/conflict/HelpContentAlreadyExistsException.java b/src/main/java/com/process/clash/application/helpcontent/exception/exception/conflict/HelpContentAlreadyExistsException.java new file mode 100644 index 000000000..63fea09f4 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/exception/exception/conflict/HelpContentAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.process.clash.application.helpcontent.exception.exception.conflict; + +import com.process.clash.application.common.exception.exception.ConflictException; +import com.process.clash.application.helpcontent.exception.statuscode.HelpContentStatusCode; + +public class HelpContentAlreadyExistsException extends ConflictException { + + public HelpContentAlreadyExistsException() { + super(HelpContentStatusCode.HELP_CONTENT_ALREADY_EXISTS); + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/exception/exception/notfound/HelpContentNotFoundException.java b/src/main/java/com/process/clash/application/helpcontent/exception/exception/notfound/HelpContentNotFoundException.java new file mode 100644 index 000000000..140b171b7 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/exception/exception/notfound/HelpContentNotFoundException.java @@ -0,0 +1,11 @@ +package com.process.clash.application.helpcontent.exception.exception.notfound; + +import com.process.clash.application.common.exception.exception.NotFoundException; +import com.process.clash.application.helpcontent.exception.statuscode.HelpContentStatusCode; + +public class HelpContentNotFoundException extends NotFoundException { + + public HelpContentNotFoundException() { + super(HelpContentStatusCode.HELP_CONTENT_NOT_FOUND); + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/exception/statuscode/HelpContentStatusCode.java b/src/main/java/com/process/clash/application/helpcontent/exception/statuscode/HelpContentStatusCode.java new file mode 100644 index 000000000..40f9174d5 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/exception/statuscode/HelpContentStatusCode.java @@ -0,0 +1,18 @@ +package com.process.clash.application.helpcontent.exception.statuscode; + +import com.process.clash.application.common.exception.statuscode.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum HelpContentStatusCode implements StatusCode { + + HELP_CONTENT_ALREADY_EXISTS("HELP_CONTENT_ALREADY_EXISTS", "이미 존재하는 도움말 키입니다.", HttpStatus.CONFLICT), + HELP_CONTENT_NOT_FOUND("HELP_CONTENT_NOT_FOUND", "도움말 내용을 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + + private final String code; + private final String message; + private final HttpStatus httpStatus; +} diff --git a/src/main/java/com/process/clash/application/helpcontent/port/in/CreateHelpContentUseCase.java b/src/main/java/com/process/clash/application/helpcontent/port/in/CreateHelpContentUseCase.java new file mode 100644 index 000000000..2414c7fb1 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/port/in/CreateHelpContentUseCase.java @@ -0,0 +1,8 @@ +package com.process.clash.application.helpcontent.port.in; + +import com.process.clash.application.helpcontent.data.CreateHelpContentData; + +public interface CreateHelpContentUseCase { + + CreateHelpContentData.Result execute(CreateHelpContentData.Command command); +} diff --git a/src/main/java/com/process/clash/application/helpcontent/port/in/GetHelpContentUseCase.java b/src/main/java/com/process/clash/application/helpcontent/port/in/GetHelpContentUseCase.java new file mode 100644 index 000000000..c8ca7d0af --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/port/in/GetHelpContentUseCase.java @@ -0,0 +1,8 @@ +package com.process.clash.application.helpcontent.port.in; + +import com.process.clash.application.helpcontent.data.GetHelpContentData; + +public interface GetHelpContentUseCase { + + GetHelpContentData.Result execute(String key); +} diff --git a/src/main/java/com/process/clash/application/helpcontent/port/in/UpdateHelpContentUseCase.java b/src/main/java/com/process/clash/application/helpcontent/port/in/UpdateHelpContentUseCase.java new file mode 100644 index 000000000..6aa94e011 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/port/in/UpdateHelpContentUseCase.java @@ -0,0 +1,8 @@ +package com.process.clash.application.helpcontent.port.in; + +import com.process.clash.application.helpcontent.data.UpdateHelpContentData; + +public interface UpdateHelpContentUseCase { + + UpdateHelpContentData.Result execute(UpdateHelpContentData.Command command); +} diff --git a/src/main/java/com/process/clash/application/helpcontent/port/out/HelpContentRepositoryPort.java b/src/main/java/com/process/clash/application/helpcontent/port/out/HelpContentRepositoryPort.java new file mode 100644 index 000000000..5e2649e55 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/port/out/HelpContentRepositoryPort.java @@ -0,0 +1,14 @@ +package com.process.clash.application.helpcontent.port.out; + +import com.process.clash.domain.helpcontent.entity.HelpContent; + +import java.util.Optional; + +public interface HelpContentRepositoryPort { + + Optional findByKey(String key); + + boolean existsByKey(String key); + + HelpContent save(HelpContent helpContent); +} diff --git a/src/main/java/com/process/clash/application/helpcontent/service/CreateHelpContentService.java b/src/main/java/com/process/clash/application/helpcontent/service/CreateHelpContentService.java new file mode 100644 index 000000000..ed8b47962 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/service/CreateHelpContentService.java @@ -0,0 +1,32 @@ +package com.process.clash.application.helpcontent.service; + +import com.process.clash.application.common.policy.CheckAdminPolicy; +import com.process.clash.application.helpcontent.data.CreateHelpContentData; +import com.process.clash.application.helpcontent.exception.exception.conflict.HelpContentAlreadyExistsException; +import com.process.clash.application.helpcontent.port.in.CreateHelpContentUseCase; +import com.process.clash.application.helpcontent.port.out.HelpContentRepositoryPort; +import com.process.clash.domain.helpcontent.entity.HelpContent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class CreateHelpContentService implements CreateHelpContentUseCase { + + private final HelpContentRepositoryPort helpContentRepositoryPort; + private final CheckAdminPolicy checkAdminPolicy; + + @Override + public CreateHelpContentData.Result execute(CreateHelpContentData.Command command) { + checkAdminPolicy.check(command.actor()); + + if (helpContentRepositoryPort.existsByKey(command.key())) { + throw new HelpContentAlreadyExistsException(); + } + + HelpContent saved = helpContentRepositoryPort.save(command.toDomain()); + return CreateHelpContentData.Result.from(saved); + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/service/GetHelpContentService.java b/src/main/java/com/process/clash/application/helpcontent/service/GetHelpContentService.java new file mode 100644 index 000000000..457e65596 --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/service/GetHelpContentService.java @@ -0,0 +1,25 @@ +package com.process.clash.application.helpcontent.service; + +import com.process.clash.application.helpcontent.data.GetHelpContentData; +import com.process.clash.application.helpcontent.exception.exception.notfound.HelpContentNotFoundException; +import com.process.clash.application.helpcontent.port.in.GetHelpContentUseCase; +import com.process.clash.application.helpcontent.port.out.HelpContentRepositoryPort; +import com.process.clash.domain.helpcontent.entity.HelpContent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetHelpContentService implements GetHelpContentUseCase { + + private final HelpContentRepositoryPort helpContentRepositoryPort; + + @Override + public GetHelpContentData.Result execute(String key) { + HelpContent helpContent = helpContentRepositoryPort.findByKey(key) + .orElseThrow(HelpContentNotFoundException::new); + return GetHelpContentData.Result.from(helpContent); + } +} diff --git a/src/main/java/com/process/clash/application/helpcontent/service/UpdateHelpContentService.java b/src/main/java/com/process/clash/application/helpcontent/service/UpdateHelpContentService.java new file mode 100644 index 000000000..28acc60ac --- /dev/null +++ b/src/main/java/com/process/clash/application/helpcontent/service/UpdateHelpContentService.java @@ -0,0 +1,38 @@ +package com.process.clash.application.helpcontent.service; + +import com.process.clash.application.common.policy.CheckAdminPolicy; +import com.process.clash.application.helpcontent.data.UpdateHelpContentData; +import com.process.clash.application.helpcontent.exception.exception.notfound.HelpContentNotFoundException; +import com.process.clash.application.helpcontent.port.in.UpdateHelpContentUseCase; +import com.process.clash.application.helpcontent.port.out.HelpContentRepositoryPort; +import com.process.clash.domain.helpcontent.entity.HelpContent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class UpdateHelpContentService implements UpdateHelpContentUseCase { + + private final HelpContentRepositoryPort helpContentRepositoryPort; + private final CheckAdminPolicy checkAdminPolicy; + + @Override + public UpdateHelpContentData.Result execute(UpdateHelpContentData.Command command) { + checkAdminPolicy.check(command.actor()); + + HelpContent existing = helpContentRepositoryPort.findByKey(command.key()) + .orElseThrow(HelpContentNotFoundException::new); + + HelpContent updated = new HelpContent( + existing.key(), + command.content(), + existing.version(), + existing.createdAt(), + null + ); + + return UpdateHelpContentData.Result.from(helpContentRepositoryPort.save(updated)); + } +} diff --git a/src/main/java/com/process/clash/domain/helpcontent/entity/HelpContent.java b/src/main/java/com/process/clash/domain/helpcontent/entity/HelpContent.java new file mode 100644 index 000000000..3c286b367 --- /dev/null +++ b/src/main/java/com/process/clash/domain/helpcontent/entity/HelpContent.java @@ -0,0 +1,12 @@ +package com.process.clash.domain.helpcontent.entity; + +import java.time.Instant; + +public record HelpContent( + String key, + String content, + Long version, + Instant createdAt, + Instant updatedAt +) { +} diff --git a/src/main/java/com/process/clash/infrastructure/config/security/SecurityConfig.java b/src/main/java/com/process/clash/infrastructure/config/security/SecurityConfig.java index 931ad9f40..c880c0755 100644 --- a/src/main/java/com/process/clash/infrastructure/config/security/SecurityConfig.java +++ b/src/main/java/com/process/clash/infrastructure/config/security/SecurityConfig.java @@ -123,6 +123,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServ ).permitAll() .requestMatchers("/api/auth/password-reset/**").permitAll() .requestMatchers("/api/config/public").permitAll() + .requestMatchers(HttpMethod.GET, "/api/help-contents/**").permitAll() .requestMatchers("/auth-login.html", "/auth-signup.html").permitAll() .requestMatchers("/admin/**", "/api/admin/**", "/api/v2/admin/**").hasRole("ADMIN") .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**", "/swagger-ui.html").permitAll() diff --git a/src/main/resources/db/migration/V43__create_help_contents.sql b/src/main/resources/db/migration/V43__create_help_contents.sql new file mode 100644 index 000000000..d288cb889 --- /dev/null +++ b/src/main/resources/db/migration/V43__create_help_contents.sql @@ -0,0 +1,73 @@ +CREATE TABLE help_contents ( + key VARCHAR(100) PRIMARY KEY, + content TEXT NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO help_contents (key, content, version) +VALUES +( + 'exp-tooltip', + $$GitHub (1시간 간격으로 반영) + +커밋: 1개당 50점 (최대 50개) +리뷰: 1개당 100점 (최대 5개) +PR: 1개당 100점 (최대 5개) +이슈: 1개당 10점 (최대 5개) + +학습 시간 + +1분당 10점 (최대 10시간) +오전 6시 갱신 +$$, + 0 +), +( + 'cookie-tooltip', + $$쿠키는 상점에서 사용 할 수 있어요! + +로드맵 챕터 클리어: 100 쿠키 +로드맵 섹션 클리어: 1,000 쿠키 + +EXP 하루 랭킹 +1위: 1,000쿠키 +2위: 500쿠키 +3위: 300쿠키 + +출석 +하루 출석: 300쿠키 (한 주 전체 출석 시: +700쿠키) +$$, + 0 +), +( + 'ranking-reward-tooltip', + $$Aura: 150,000 EXP & 상위 1등 +Master: 100,000 EXP & 상위 3등 +Diamond: 50,000 EXP +Gold: 30,000 EXP +Silver: 10,000 EXP +Bronze: 1,000 EXP$$, + 0 +), +( + 'chapter-ranking-tooltip', + '챕터 랭킹은 상위 20명까지 표시됩니다.', + 0 +), +( + 'activity-analysis-tooltip', + 'GitHub 관련 데이터는 1시간마다 업데이트 됩니다.', + 0 +), +( + 'major-change-tooltip', + '전공 테스트와 새로운 전공을 만나보세요', + 0 +), +( + 'rival-limit-tooltip', + '최대 라이벌 수는 4명입니다.', + 0 +); diff --git a/src/test/java/com/process/clash/adapter/persistence/helpcontent/HelpContentPersistenceAdapterTest.java b/src/test/java/com/process/clash/adapter/persistence/helpcontent/HelpContentPersistenceAdapterTest.java new file mode 100644 index 000000000..99189ba72 --- /dev/null +++ b/src/test/java/com/process/clash/adapter/persistence/helpcontent/HelpContentPersistenceAdapterTest.java @@ -0,0 +1,53 @@ +package com.process.clash.adapter.persistence.helpcontent; + +import com.process.clash.domain.helpcontent.entity.HelpContent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HelpContentPersistenceAdapterTest { + + @Mock + private HelpContentJpaRepository helpContentJpaRepository; + + @Test + @DisplayName("저장 후 flush된 version과 updatedAt을 반환한다") + void save_returnsFlushedEntity() { + HelpContentPersistenceAdapter adapter = new HelpContentPersistenceAdapter( + helpContentJpaRepository, + new HelpContentJpaMapper() + ); + Instant createdAt = Instant.parse("2026-07-15T00:00:00Z"); + Instant updatedAt = Instant.parse("2026-07-15T01:00:00Z"); + HelpContentJpaEntity flushedEntity = new HelpContentJpaEntity( + "cookie-tooltip", + "변경 문구", + 4L, + createdAt, + updatedAt + ); + when(helpContentJpaRepository.saveAndFlush(any())).thenReturn(flushedEntity); + + HelpContent saved = adapter.save(new HelpContent( + "cookie-tooltip", + "변경 문구", + 3L, + createdAt, + null + )); + + verify(helpContentJpaRepository).saveAndFlush(any()); + assertThat(saved.version()).isEqualTo(4L); + assertThat(saved.updatedAt()).isEqualTo(updatedAt); + } +} diff --git a/src/test/java/com/process/clash/adapter/web/helpcontent/controller/HelpContentControllerTest.java b/src/test/java/com/process/clash/adapter/web/helpcontent/controller/HelpContentControllerTest.java new file mode 100644 index 000000000..b2da43fe0 --- /dev/null +++ b/src/test/java/com/process/clash/adapter/web/helpcontent/controller/HelpContentControllerTest.java @@ -0,0 +1,77 @@ +package com.process.clash.adapter.web.helpcontent.controller; + +import com.process.clash.adapter.web.common.GlobalExceptionHandler; +import com.process.clash.application.helpcontent.data.GetHelpContentData; +import com.process.clash.application.helpcontent.exception.exception.notfound.HelpContentNotFoundException; +import com.process.clash.application.helpcontent.port.in.GetHelpContentUseCase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class HelpContentControllerTest { + + @Mock + private GetHelpContentUseCase getHelpContentUseCase; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new HelpContentController(getHelpContentUseCase)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("GET /api/help-contents/{key} 는 내용과 ETag를 반환한다") + void getHelpContent_returnsContentAndEtag() throws Exception { + when(getHelpContentUseCase.execute("cookie-tooltip")) + .thenReturn(new GetHelpContentData.Result("cookie-tooltip", "쿠키 안내", 3)); + + mockMvc.perform(get("/api/help-contents/cookie-tooltip")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, "\"3\"")) + .andExpect(content().contentTypeCompatibleWith("text/plain")) + .andExpect(content().string("쿠키 안내")); + } + + @Test + @DisplayName("동일한 If-None-Match를 전달하면 304를 반환한다") + void getHelpContent_whenEtagMatches_returnsNotModified() throws Exception { + when(getHelpContentUseCase.execute("cookie-tooltip")) + .thenReturn(new GetHelpContentData.Result("cookie-tooltip", "쿠키 안내", 3)); + + mockMvc.perform(get("/api/help-contents/cookie-tooltip") + .header(HttpHeaders.IF_NONE_MATCH, "\"3\"")) + .andExpect(status().isNotModified()) + .andExpect(header().string(HttpHeaders.ETAG, "\"3\"")) + .andExpect(content().string("")); + } + + @Test + @DisplayName("존재하지 않는 도움말 조회는 JSON 오류 응답을 반환한다") + void getHelpContent_whenNotFound_returnsJsonError() throws Exception { + when(getHelpContentUseCase.execute("unknown-tooltip")) + .thenThrow(new HelpContentNotFoundException()); + + mockMvc.perform(get("/api/help-contents/unknown-tooltip")) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("HELP_CONTENT_NOT_FOUND")); + } +} diff --git a/src/test/java/com/process/clash/application/helpcontent/service/CreateHelpContentServiceTest.java b/src/test/java/com/process/clash/application/helpcontent/service/CreateHelpContentServiceTest.java new file mode 100644 index 000000000..7823b8cb1 --- /dev/null +++ b/src/test/java/com/process/clash/application/helpcontent/service/CreateHelpContentServiceTest.java @@ -0,0 +1,79 @@ +package com.process.clash.application.helpcontent.service; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.common.policy.CheckAdminPolicy; +import com.process.clash.application.helpcontent.data.CreateHelpContentData; +import com.process.clash.application.helpcontent.exception.exception.conflict.HelpContentAlreadyExistsException; +import com.process.clash.application.helpcontent.port.out.HelpContentRepositoryPort; +import com.process.clash.domain.helpcontent.entity.HelpContent; +import com.process.clash.domain.user.user.enums.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CreateHelpContentServiceTest { + + @Mock + private HelpContentRepositoryPort helpContentRepositoryPort; + + private CreateHelpContentService service; + + @BeforeEach + void setUp() { + service = new CreateHelpContentService(helpContentRepositoryPort, new CheckAdminPolicy()); + } + + @Test + @DisplayName("관리자가 새 도움말을 생성하면 JPA가 초기 버전을 부여한다") + void createHelpContent_savesInitialVersion() { + HelpContent saved = new HelpContent( + "new-tooltip", + "새 안내 문구", + 0L, + Instant.parse("2026-07-14T00:00:00Z"), + Instant.parse("2026-07-14T00:00:00Z") + ); + when(helpContentRepositoryPort.existsByKey("new-tooltip")).thenReturn(false); + when(helpContentRepositoryPort.save(org.mockito.ArgumentMatchers.any())).thenReturn(saved); + + CreateHelpContentData.Result result = service.execute(new CreateHelpContentData.Command( + new Actor(1L, Role.ADMIN), + "new-tooltip", + "새 안내 문구" + )); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HelpContent.class); + verify(helpContentRepositoryPort).save(captor.capture()); + assertThat(captor.getValue().version()).isNull(); + assertThat(captor.getValue().key()).isEqualTo("new-tooltip"); + assertThat(result.version()).isZero(); + } + + @Test + @DisplayName("이미 존재하는 도움말 키는 생성할 수 없다") + void createHelpContent_whenKeyExists_throwsConflict() { + when(helpContentRepositoryPort.existsByKey("cookie-tooltip")).thenReturn(true); + + assertThatThrownBy(() -> service.execute(new CreateHelpContentData.Command( + new Actor(1L, Role.ADMIN), + "cookie-tooltip", + "새 안내 문구" + ))) + .isInstanceOf(HelpContentAlreadyExistsException.class); + + verify(helpContentRepositoryPort, never()).save(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/com/process/clash/application/helpcontent/service/UpdateHelpContentServiceTest.java b/src/test/java/com/process/clash/application/helpcontent/service/UpdateHelpContentServiceTest.java new file mode 100644 index 000000000..b40ef0290 --- /dev/null +++ b/src/test/java/com/process/clash/application/helpcontent/service/UpdateHelpContentServiceTest.java @@ -0,0 +1,70 @@ +package com.process.clash.application.helpcontent.service; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.common.policy.CheckAdminPolicy; +import com.process.clash.application.helpcontent.data.UpdateHelpContentData; +import com.process.clash.application.helpcontent.port.out.HelpContentRepositoryPort; +import com.process.clash.domain.helpcontent.entity.HelpContent; +import com.process.clash.domain.user.user.enums.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UpdateHelpContentServiceTest { + + @Mock + private HelpContentRepositoryPort helpContentRepositoryPort; + + private UpdateHelpContentService service; + + @BeforeEach + void setUp() { + service = new UpdateHelpContentService(helpContentRepositoryPort, new CheckAdminPolicy()); + } + + @Test + @DisplayName("관리자가 도움말을 수정하면 내용과 버전이 함께 갱신된다") + void updateHelpContent_updatesContentAndIncrementsVersion() { + HelpContent existing = new HelpContent( + "cookie-tooltip", + "기존 문구", + 3L, + Instant.parse("2026-07-14T00:00:00Z"), + Instant.parse("2026-07-14T00:00:00Z") + ); + HelpContent saved = new HelpContent( + "cookie-tooltip", + "변경 문구", + 4L, + existing.createdAt(), + Instant.parse("2026-07-14T01:00:00Z") + ); + when(helpContentRepositoryPort.findByKey("cookie-tooltip")).thenReturn(Optional.of(existing)); + when(helpContentRepositoryPort.save(org.mockito.ArgumentMatchers.any())).thenReturn(saved); + + UpdateHelpContentData.Result result = service.execute(new UpdateHelpContentData.Command( + new Actor(1L, Role.ADMIN), + "cookie-tooltip", + "변경 문구" + )); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HelpContent.class); + verify(helpContentRepositoryPort).save(captor.capture()); + assertThat(captor.getValue().content()).isEqualTo("변경 문구"); + assertThat(captor.getValue().version()).isEqualTo(3); + assertThat(result.version()).isEqualTo(4); + assertThat(result.content()).isEqualTo("변경 문구"); + } +}