Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.sniptext.domain.template.annotation;

import com.sniptext.domain.template.entity.UsageChannel;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

/** UsageChannel의 상수 이름과 정확히 같은지만 본다. 위반 메시지는 어노테이션 기본값(TEMPLATE400_9) 그대로다. */
public class UsageChannelConstraintValidator implements ConstraintValidator<ValidUsageChannel, String> {

@Override
public boolean isValid(String channel, ConstraintValidatorContext context) {
return UsageChannel.isSupported(channel);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.sniptext.domain.template.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

/**
* 요청 객체의 사용 채널 검증. 없거나(null 포함) 지원하지 않으면 TEMPLATE400_9.
* 필드를 열거형으로 받으면 틀린 값이 JSON 역직렬화 실패(COMMON400_1)가 되므로 문자열로 받아 여기서 검사한다.
* UsageChannel(entity)을 쓰므로 controller 패키지 밖에 둔다 (ArchUnit 의존 규칙).
*/
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UsageChannelConstraintValidator.class)
public @interface ValidUsageChannel {

String message() default "TEMPLATE400_9";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
import com.sniptext.domain.template.controller.dto.TemplateListResponse;
import com.sniptext.domain.template.controller.dto.TemplateUpdateRequest;
import com.sniptext.domain.template.controller.dto.TemplateUpdateResponse;
import com.sniptext.domain.template.controller.dto.TemplateUsageRequest;
import com.sniptext.domain.template.controller.dto.TemplateUsageResponse;
import com.sniptext.domain.template.service.TemplateCommandService;
import com.sniptext.domain.template.service.TemplateQueryService;
import com.sniptext.domain.template.service.dto.query.TemplateDetailQuery;
import com.sniptext.domain.template.service.dto.result.TemplateCreateResult;
import com.sniptext.domain.template.service.dto.result.TemplateDetailResult;
import com.sniptext.domain.template.service.dto.result.TemplateListResult;
import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult;
import com.sniptext.domain.template.service.dto.result.TemplateUsageResult;
import com.sniptext.global.response.ApiResponse;
import com.sniptext.global.security.LoginUser;

Expand Down Expand Up @@ -77,4 +80,15 @@ public ResponseEntity<ApiResponse<TemplateUpdateResponse>> updateTemplate(
return ApiResponse.success(TemplateSuccessCode.TEMPLATE_UPDATED, TemplateUpdateResponse.from(result));
}

@PostMapping("/{templateId}/usages")
public ResponseEntity<ApiResponse<TemplateUsageResponse>> recordUsage(
@LoginUser Long userId,
@PathVariable Long templateId,
@Valid @RequestBody TemplateUsageRequest request) {

TemplateUsageResult result = templateCommandService.recordUsage(request.toCommand(userId, templateId));

return ApiResponse.success(TemplateSuccessCode.TEMPLATE_USAGE_RECORDED, TemplateUsageResponse.from(result));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum TemplateSuccessCode implements ResultCode {
TEMPLATE_LIST_FOUND(HttpStatus.OK, "TEMPLATE200_1", "템플릿 목록 조회에 성공했습니다."),
TEMPLATE_FOUND(HttpStatus.OK, "TEMPLATE200_2", "템플릿 조회에 성공했습니다."),
TEMPLATE_UPDATED(HttpStatus.OK, "TEMPLATE200_3", "수정 내용이 저장되었습니다."),
TEMPLATE_USAGE_RECORDED(HttpStatus.OK, "TEMPLATE200_6", "사용 기록이 저장되었습니다."),
TEMPLATE_CREATED(HttpStatus.CREATED, "TEMPLATE201_1", "템플릿이 저장되었습니다.");

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.sniptext.domain.template.controller.dto;

import com.sniptext.domain.template.annotation.ValidUsageChannel;
import com.sniptext.domain.template.service.dto.command.TemplateUsageCommand;

/**
* 템플릿 사용 기록 (명세 14번). 채널은 열거형 대신 문자열로 받아 @ValidUsageChannel로 검사한다
* (틀린 값이 COMMON400_1이 아니라 TEMPLATE400_9가 되도록).
*/
public record TemplateUsageRequest(
@ValidUsageChannel String channel
) {

public TemplateUsageCommand toCommand(Long userId, Long templateId) {
return new TemplateUsageCommand(userId, templateId);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sniptext.domain.template.controller.dto;

import java.time.LocalDateTime;

import com.sniptext.domain.template.service.dto.result.TemplateUsageResult;

/** 템플릿 사용 기록 결과 (명세 14번). 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */
public record TemplateUsageResponse(
Long templateId,
LocalDateTime lastUsedAt
) {

public static TemplateUsageResponse from(TemplateUsageResult result) {
return new TemplateUsageResponse(result.templateId(), result.lastUsedAt());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.sniptext.domain.template.entity;

import java.util.Arrays;

/**
* 템플릿 사용 채널 (명세 공통 규칙 6장). 상수 이름이 API 값이다.
* 저장하지 않는 값이다. 채널별 통계는 화면이 분석 도구 이벤트로 따로 보낸다.
*/
public enum UsageChannel {

/** 대시보드에서 복사 */
DASHBOARD_COPY,
/** 확장 팝업에서 복사 */
POPUP_COPY,
/** 메일 작성 창에 인라인 삽입 */
EMAIL_INLINE;

/** 상수 이름과 정확히 같을 때만 지원한다. 대소문자·앞뒤 공백을 고쳐 주지 않는다 */
public static boolean isSupported(String value) {
return Arrays.stream(values()).anyMatch(channel -> channel.name().equals(value));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public enum TemplateErrorCode implements ResultCode {
CONTENT_EMPTY(HttpStatus.BAD_REQUEST, "TEMPLATE400_6", "템플릿 내용을 입력해주세요."),
/** 본문 3000자 초과 */
CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "TEMPLATE400_7", "템플릿 내용은 3,000자까지 입력할 수 있어요."),
/** 사용 기록의 채널이 없거나 지원하지 않는 값 */
USAGE_CHANNEL_UNSUPPORTED(HttpStatus.BAD_REQUEST, "TEMPLATE400_9", "지원하지 않는 사용 채널이에요."),
/** 활성 템플릿 수가 플랜 한도 이상 (화면은 한도 모달) */
TEMPLATE_LIMIT_REACHED(HttpStatus.FORBIDDEN, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."),
/** 없는 템플릿, 다른 회원의 템플릿 (다른 회원의 휴지통 템플릿 포함) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
import com.sniptext.domain.template.service.dto.command.TemplateCreateCommand;
import com.sniptext.domain.template.service.dto.command.TemplateCreateFromGalleryCommand;
import com.sniptext.domain.template.service.dto.command.TemplateUpdateCommand;
import com.sniptext.domain.template.service.dto.command.TemplateUsageCommand;
import com.sniptext.domain.template.service.dto.result.TemplateCreateResult;
import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult;
import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult;
import com.sniptext.domain.template.service.dto.result.TemplateUsageResult;
import com.sniptext.domain.user.service.UserCommandService;
import com.sniptext.global.exception.BusinessException;
import com.sniptext.global.policy.UsageLimits;
Expand Down Expand Up @@ -117,6 +119,22 @@ public TemplateUpdateResult update(TemplateUpdateCommand command) {
return TemplateUpdateResult.from(save(template));
}

/**
* 템플릿 사용 기록. 최근 사용 시각(last_used_at)만 Clock 시각으로 바꾸고 수정 시각(edited_at)은 그대로 둔다.
* 없거나 다른 회원 것이면 TEMPLATE404_1, 휴지통에 있으면 TEMPLATE404_2. 개수가 바뀌지 않으므로 회원을 잠그지 않는다.
* 변경 감지로 쓴다. @DynamicUpdate라 UPDATE에는 last_used_at과 감사 기능이 채우는 updated_at만 들어가고,
* 읽은 뒤 다른 요청이 고친 이름·본문을 덮어쓰지 않는다. 엔티티를 읽으므로 본문 복호화에 실패하는 행이면 500이다.
* 조회 뒤 커밋 전에 휴지통으로 옮겨지면 휴지통 행의 last_used_at이 바뀐다. 표시하지 않는 값이고 복구 시 그대로 남는 정도라 허용한다.
*/
@Transactional
public TemplateUsageResult recordUsage(TemplateUsageCommand command) {
Template template = templateQueryService.getActiveTemplate(command.userId(), command.templateId());

template.markUsed(LocalDateTime.now(clock));

return TemplateUsageResult.from(template);
}

private static void requireNoOuterTransaction() {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException("갤러리 저장은 호출자 트랜잭션 밖에서 불러야 한다");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.sniptext.domain.template.service.dto.command;

/** 템플릿 사용 기록 입력. 채널은 요청 단계에서 검증만 하고 저장하지 않으므로 넘기지 않는다. */
public record TemplateUsageCommand(
Long userId,
Long templateId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sniptext.domain.template.service.dto.result;

import java.time.LocalDateTime;

import com.sniptext.domain.template.entity.Template;

/** 사용 기록을 남긴 템플릿과 갱신된 최근 사용 시각. */
public record TemplateUsageResult(
Long templateId,
LocalDateTime lastUsedAt
) {

public static TemplateUsageResult from(Template template) {
return new TemplateUsageResult(template.getId(), template.getLastUsedAt());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.sniptext.domain.template.annotation;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;

class ValidUsageChannelTest {

private static ValidatorFactory validatorFactory;
private static Validator validator;

record Request(@ValidUsageChannel String channel) {
}

@BeforeAll
static void setUp() {
validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}

@AfterAll
static void tearDown() {
validatorFactory.close();
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "dashboard_copy", "1", "SLACK_COPY"})
@DisplayName("없거나 지원하지 않는 채널이면 TEMPLATE400_9 하나만 남긴다")
void unsupported(String channel) {
assertThat(messagesOf(channel)).containsExactly("TEMPLATE400_9");
}

@ParameterizedTest
@ValueSource(strings = {"DASHBOARD_COPY", "POPUP_COPY", "EMAIL_INLINE"})
@DisplayName("명세의 채널이면 위반 없음")
void supported(String channel) {
assertThat(messagesOf(channel)).isEmpty();
}

private static List<String> messagesOf(String channel) {
return validator.validate(new Request(channel)).stream()
.map(ConstraintViolation::getMessage)
.toList();
}

}
Loading
Loading