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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.sniptext.domain.template.controller.dto.TemplateCreateRequest;
import com.sniptext.domain.template.controller.dto.TemplateCreateResponse;
import com.sniptext.domain.template.controller.dto.TemplateDeleteResponse;
import com.sniptext.domain.template.controller.dto.TemplateDetailResponse;
import com.sniptext.domain.template.controller.dto.TemplateListResponse;
import com.sniptext.domain.template.controller.dto.TemplateShortcutAvailabilityResponse;
import com.sniptext.domain.template.controller.dto.TemplateUpdateRequest;
import com.sniptext.domain.template.controller.dto.TemplateUpdateResponse;
import com.sniptext.domain.template.controller.dto.TemplateUsageRequest;
Expand All @@ -23,10 +25,12 @@
import com.sniptext.domain.template.service.TemplateQueryService;
import com.sniptext.domain.template.service.dto.command.TemplateDeleteCommand;
import com.sniptext.domain.template.service.dto.query.TemplateDetailQuery;
import com.sniptext.domain.template.service.dto.query.TemplateShortcutAvailabilityQuery;
import com.sniptext.domain.template.service.dto.result.TemplateCreateResult;
import com.sniptext.domain.template.service.dto.result.TemplateDeleteResult;
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.TemplateShortcutAvailabilityResult;
import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult;
import com.sniptext.domain.template.service.dto.result.TemplateUsageResult;
import com.sniptext.global.response.ApiResponse;
Expand Down Expand Up @@ -94,6 +98,23 @@ public ResponseEntity<ApiResponse<TemplateDeleteResponse>> deleteTemplate(
return ApiResponse.success(TemplateSuccessCode.TEMPLATE_DELETED, TemplateDeleteResponse.from(result));
}

/**
* 단축어 확인. 형식 오류·예약어·중복도 200 + reason이라 shortcut에 검증 어노테이션을 붙이지 않는다.
* shortcut 파라미터가 없거나 excludeTemplateId가 숫자가 아니면 스프링이 COMMON400_1로 응답한다.
*/
@GetMapping("/shortcuts/availability")
public ResponseEntity<ApiResponse<TemplateShortcutAvailabilityResponse>> checkShortcutAvailability(
@LoginUser Long userId,
@RequestParam String shortcut,
@RequestParam(required = false) Long excludeTemplateId) {

TemplateShortcutAvailabilityResult result = templateQueryService.checkShortcutAvailability(
new TemplateShortcutAvailabilityQuery(userId, shortcut, excludeTemplateId));

return ApiResponse.success(TemplateSuccessCode.SHORTCUT_AVAILABILITY_CHECKED,
TemplateShortcutAvailabilityResponse.from(result));
}

@PostMapping("/{templateId}/usages")
public ResponseEntity<ApiResponse<TemplateUsageResponse>> recordUsage(
@LoginUser Long userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public enum TemplateSuccessCode implements ResultCode {
TEMPLATE_FOUND(HttpStatus.OK, "TEMPLATE200_2", "템플릿 조회에 성공했습니다."),
TEMPLATE_UPDATED(HttpStatus.OK, "TEMPLATE200_3", "수정 내용이 저장되었습니다."),
TEMPLATE_DELETED(HttpStatus.OK, "TEMPLATE200_4", "템플릿을 휴지통으로 옮겼습니다."),
SHORTCUT_AVAILABILITY_CHECKED(HttpStatus.OK, "TEMPLATE200_5", "단축어 확인에 성공했습니다."),
TEMPLATE_USAGE_RECORDED(HttpStatus.OK, "TEMPLATE200_6", "사용 기록이 저장되었습니다."),
TEMPLATE_CREATED(HttpStatus.CREATED, "TEMPLATE201_1", "템플릿이 저장되었습니다.");

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

import com.sniptext.domain.template.entity.ShortcutUnavailableReason;
import com.sniptext.domain.template.service.dto.result.TemplateShortcutAvailabilityResult;

/**
* 단축어 확인 결과 (명세 13번). shortcut은 확인한 단축어를 NFC 정규화한 값(대소문자는 입력 그대로)이다.
* 사용 가능하면 reason은 null로 내보낸다.
*/
public record TemplateShortcutAvailabilityResponse(
String shortcut,
boolean available,
ShortcutUnavailableReason reason
) {

public static TemplateShortcutAvailabilityResponse from(TemplateShortcutAvailabilityResult result) {
return new TemplateShortcutAvailabilityResponse(result.shortcut(), result.available(), result.reason());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ public int hashCode() {
return Objects.hashCode(value);
}

private static String normalize(String shortcut) {
/**
* 표시용 값과 같은 방식으로 정규화한다 (NFC, 대소문자 그대로). 규칙을 어긴 입력에도 쓸 수 있다.
* 단축어 확인 API가 형식이 틀린 입력도 정규화한 값으로 돌려줄 때 쓴다. null은 받지 않는다.
*/
public static String normalize(String shortcut) {
return Normalizer.normalize(shortcut, Normalizer.Form.NFC);
}

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

/**
* 단축어 확인 API(명세 13번)의 사용 불가 이유. 상수 이름이 API 값이다 (저장하지 않는 값).
* 확인 API에는 빈 값 사유가 따로 없어 빈 값도 INVALID_FORMAT으로 내려준다. 빈 값 안내는 저장 시점의 TEMPLATE400_3이 한다.
*/
public enum ShortcutUnavailableReason {

/** 비었거나 "/"만 있거나, "/" 뒤 한글(완성형·자모)·영문·숫자 2~10자가 아님 */
INVALID_FORMAT,
/** 예약어 (/톤교정) */
RESERVED,
/** 내 활성 템플릿(휴지통 제외)이 같은 비교 키를 씀 */
DUPLICATED;

public static ShortcutUnavailableReason from(ShortcutViolation violation) {
return switch (violation) {
case EMPTY, INVALID_FORMAT -> INVALID_FORMAT;
case RESERVED -> RESERVED;
};
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

/**
* 단축어가 규칙을 어긴 이유. 검사 순서대로 나열한다 (저장하지 않는 값).
* API로 그대로 내보내지 않는 내부 값이다. 단축어 확인 API의 reason과 값의 종류가 다르다 (EMPTY 없음, DUPLICATED 있음).
* API로 그대로 내보내지 않는 내부 값이다. 단축어 확인 API의 reason은 ShortcutUnavailableReason으로 바꿔 내보낸다
* (EMPTY 없음, DUPLICATED 있음).
*/
public enum ShortcutViolation {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package com.sniptext.domain.template.service;

import java.util.List;
import java.util.Optional;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sniptext.domain.template.entity.Shortcut;
import com.sniptext.domain.template.entity.ShortcutUnavailableReason;
import com.sniptext.domain.template.entity.ShortcutViolation;
import com.sniptext.domain.template.entity.Template;
import com.sniptext.domain.template.exception.TemplateErrorCode;
import com.sniptext.domain.template.repository.TemplateRepository;
import com.sniptext.domain.template.service.dto.query.TemplateDetailQuery;
import com.sniptext.domain.template.service.dto.query.TemplateShortcutAvailabilityQuery;
import com.sniptext.domain.template.service.dto.result.TemplateDetailResult;
import com.sniptext.domain.template.service.dto.result.TemplateListItemResult;
import com.sniptext.domain.template.service.dto.result.TemplateListResult;
import com.sniptext.domain.template.service.dto.result.TemplateShortcutAvailabilityResult;
import com.sniptext.global.exception.BusinessException;

import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -42,6 +48,27 @@ public TemplateDetailResult getTemplate(TemplateDetailQuery query) {
return TemplateDetailResult.from(getActiveTemplate(query.userId(), query.templateId()));
}

/**
* 단축어 확인. 저장과 같은 순서(NFC 정규화 → 형식 → 예약어 → 중복)로 보고, 규칙 위반도 에러 대신 사유로 돌려준다.
* 중복은 내 활성 템플릿(휴지통 제외)의 비교 키로 판정하고, excludeTemplateId가 있으면 그 템플릿만 뺀다.
* 남의 템플릿·휴지통 ID를 넘겨도 내 활성 템플릿에 없는 ID라 결과가 같으므로 따로 확인하지 않는다.
* 저장 순간 다른 요청이 먼저 등록할 수 있어 회원을 잠그지 않는다 (최종 검증은 저장 API의 TEMPLATE409_1).
*/
public TemplateShortcutAvailabilityResult checkShortcutAvailability(TemplateShortcutAvailabilityQuery query) {
Optional<ShortcutViolation> violation = Shortcut.findViolation(query.shortcut());
if (violation.isPresent()) {
return TemplateShortcutAvailabilityResult.unavailable(Shortcut.normalize(query.shortcut()),
ShortcutUnavailableReason.from(violation.get()));
}

Shortcut shortcut = Shortcut.create(query.shortcut());
if (isShortcutTaken(query.userId(), shortcut, query.excludeTemplateId())) {
return TemplateShortcutAvailabilityResult.unavailable(shortcut.getValue(),
ShortcutUnavailableReason.DUPLICATED);
}
return TemplateShortcutAvailabilityResult.available(shortcut.getValue());
}

/**
* 휴지통에 없는 본인 템플릿. 수정·사용 기록도 같은 404 구분을 쓴다.
* 엔티티를 돌려주므로 template 도메인 안에서만 부른다 (패키지 범위라 service 패키지 밖에서는 부를 수 없다).
Expand All @@ -55,4 +82,12 @@ Template getActiveTemplate(Long userId, Long templateId) {
return template;
}

private boolean isShortcutTaken(Long userId, Shortcut shortcut, Long excludeTemplateId) {
if (excludeTemplateId == null) {
return templateRepository.existsByUserIdAndActiveShortcutKey(userId, shortcut.getKey());
}
return templateRepository.existsByUserIdAndActiveShortcutKeyAndIdNot(userId, shortcut.getKey(),
excludeTemplateId);
}

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

/** 단축어 확인 입력. excludeTemplateId는 편집 중인 자기 자신이고, 없으면 null이다. */
public record TemplateShortcutAvailabilityQuery(
Long userId,
String shortcut,
Long excludeTemplateId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.sniptext.domain.template.service.dto.result;

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

/** 단축어 확인 결과. shortcut은 NFC 정규화한 값이고, 사용 가능하면 reason은 null이다. */
public record TemplateShortcutAvailabilityResult(
String shortcut,
boolean available,
ShortcutUnavailableReason reason
) {

public static TemplateShortcutAvailabilityResult available(String shortcut) {
return new TemplateShortcutAvailabilityResult(shortcut, true, null);
}

public static TemplateShortcutAvailabilityResult unavailable(String shortcut, ShortcutUnavailableReason reason) {
return new TemplateShortcutAvailabilityResult(shortcut, false, reason);
}

}
Loading
Loading