[api] 크로스도메인 등록 목록 작업

This commit is contained in:
2026-05-21 11:56:22 +09:00
parent ada6dfc7b4
commit 41b3cd5b33
19 changed files with 391 additions and 39 deletions
+2
View File
@@ -19,6 +19,7 @@
- DTO 안에 연관된 다른 DTO 변환이 꼭 필요할 때만 최소한의 보조 메서드를 둔다.
- 외부 응답에 노출되면 안 되는 내부 필드는 응답에 사용될 수 있는 객체에서 `@JsonIgnore`로 숨긴다.
- Form 에서 DTO 로 변환할 때는 검증과 `trim()`, 기본값 치환까지 같이 처리하는 현재 패턴을 우선 따른다.
- 목록 조회 API 는 특별한 사유가 없으면 기본적으로 페이징을 적용하고, 검색 Form/Dto 는 `PageRequest`, 목록 응답 Vo 는 `PageResponse` 를 상속한다.
## Swagger/OpenAPI 문서화 규칙
- 신규 또는 수정되는 Controller, Form, VO에는 Swagger 테스트 편의성을 위한 설명을 반드시 남긴다.
@@ -63,3 +64,4 @@
- Controller 응답은 `ResponseEntity<ApiResponse<T>>` 를 기본으로 사용한다. 파일 다운로드처럼 바이너리 응답이 필요한 경우만 예외로 둔다.
- Controller 에서는 `@RequestBody`, `@PathVariable`, `@RequestHeader`, `@CookieValue` 를 명시적으로 선언해 요청 출처를 드러낸다.
- 인증 사용자 확인은 모듈 구현에 따라 `HttpSession` 또는 `SecurityContextHolder` 를 사용하므로 기존 방식을 먼저 맞춘다.
- 목록 조회는 Controller 에서 `@ModelAttribute` 검색 Form 을 받고, Service 에서 `totalCount` 와 목록을 조회한 뒤 `PageResponse#setPaging(...)` 으로 페이징 정보를 채운다.
@@ -1,7 +1,7 @@
package com.alist.api.config.cache;
import com.alist.api.modules.cors.vo.CorsOriginVo;
import com.alist.api.modules.cors.mapper.CorsMapper;
import com.alist.api.modules.cors.mapper.CorsAllowMapper;
import com.alist.api.modules.cors.vo.CorsOriginCacheVo;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -17,7 +17,7 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class CorsAllowedOriginsCache {
private final CorsMapper corsMapper;
private final CorsAllowMapper corsAllowMapper;
// Thread-safe List (volatile로 가시성 보장)
private volatile List<String> allowedOrigins = new ArrayList<>();
@@ -36,10 +36,10 @@ public class CorsAllowedOriginsCache {
*/
public synchronized void refresh() {
try {
List<String> origins = corsMapper.selectCorsAllowedList()
List<String> origins = corsAllowMapper.selectCorsAllowedCacheList()
.stream()
.filter(Objects::nonNull)
.map(CorsOriginVo::getAllowedOrigin)
.map(CorsOriginCacheVo::getAllowedOrigin)
.filter(Objects::nonNull)
.map(String::trim)
.filter(origin -> !origin.isEmpty())
@@ -3,23 +3,67 @@ package com.alist.api.modules.cors;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import com.alist.api.config.cache.CorsAllowedOriginsCache;
import com.alist.api.modules.cors.form.CorsOriginAddForm;
import com.alist.api.modules.cors.form.CorsOriginDeleteForm;
import com.alist.api.modules.cors.form.CorsOriginSearchForm;
import com.alist.api.modules.cors.service.CorsAllowService;
import com.alist.api.modules.cors.vo.CorsOriginAddVo;
import com.alist.api.modules.cors.vo.CorsOriginDeleteVo;
import com.alist.api.modules.cors.vo.CorsOriginListVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "901. CRS 갱신", description = "테스트 CORS 설정 관리 API")
@RestController
@RequestMapping("/cors")
@RequiredArgsConstructor
public class CorsAllowController {
private final CorsAllowedOriginsCache corsCache;
private final CorsAllowService corsAllowService;
public CorsAllowController(CorsAllowedOriginsCache corsCache, CorsAllowService corsAllowService) {
this.corsCache = corsCache;
this.corsAllowService = corsAllowService;
}
@Operation(
summary = "CORS 허용 도메인 목록 조회",
description = "DB에 등록된 CORS 허용 도메인 목록을 검색 조건과 페이징으로 조회합니다."
)
@GetMapping("/list")
public ResponseEntity<ApiResponse<CorsOriginListVo>> corsAllowedList(
@ModelAttribute CorsOriginSearchForm corsOriginSearchForm
) {
CorsOriginListVo result = corsAllowService.selectCorsAllowedList(corsOriginSearchForm.toCorsOriginSearchDto());
if (result.getCorsOriginList() == null || result.getCorsOriginList().isEmpty()) {
return ApiResponse.entity(result, ApiResponseCode.CODE_2003);
}
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "CORS 허용 도메인");
}
@Operation(
summary = "CORS 허용 도메인 추가",
description = "CORS 허용 도메인을 추가합니다. 추가 후 CORS 캐시를 갱신합니다."
)
@PostMapping("/add")
public ResponseEntity<ApiResponse<CorsOriginAddVo>> addCorsAllowedOrigin(
@Valid @RequestBody CorsOriginAddForm corsOriginAddForm
) {
CorsOriginAddVo result = corsAllowService.insertCorsAllowedOrigin(corsOriginAddForm.toCorsOriginAddDto());
if (!result.isCorsOriginAdded()) {
return ApiResponse.entity(result, ApiResponseCode.CODE_2004, "CORS 허용 도메인");
}
corsCache.refresh();
return ApiResponse.entity(result, ApiResponseCode.CODE_2002, "CORS 허용 도메인");
}
@Operation(
summary = "CORS 화이트리스트 갱신",
@@ -31,4 +75,22 @@ public class CorsAllowController {
List<String> cacheStatus = corsCache.getCacheStatus();
return ApiResponse.entity(cacheStatus, ApiResponseCode.CODE_200);
}
@Operation(
summary = "CORS 허용 도메인 삭제",
description = "CORS 허용 도메인 PK 기준으로 삭제 처리합니다. 실제 삭제가 아닌 del_yn 변경 방식입니다."
)
@PatchMapping("/delete")
public ResponseEntity<ApiResponse<CorsOriginDeleteVo>> deleteCorsAllowedOrigin(
@Valid @RequestBody CorsOriginDeleteForm corsOriginDeleteForm
) {
CorsOriginDeleteVo result = corsAllowService.deleteCorsAllowedOrigin(corsOriginDeleteForm.toCorsOriginDeleteDto());
if (!result.isCorsOriginDeleted()) {
return ApiResponse.entity(result, ApiResponseCode.CODE_2003);
}
corsCache.refresh();
return ApiResponse.entity(result, ApiResponseCode.CODE_2005, "CORS 허용 도메인 삭제");
}
}
@@ -0,0 +1,10 @@
package com.alist.api.modules.cors.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CorsOriginAddDto {
private String allowedOrigin;
}
@@ -0,0 +1,10 @@
package com.alist.api.modules.cors.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CorsOriginDeleteDto {
private Integer corsIdx;
}
@@ -0,0 +1,11 @@
package com.alist.api.modules.cors.dto;
import com.alist.api.common.paging.PageRequest;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CorsOriginSearchDto extends PageRequest {
private String keyword;
}
@@ -0,0 +1,28 @@
package com.alist.api.modules.cors.form;
import com.alist.api.modules.cors.dto.CorsOriginAddDto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.Setter;
@Schema(description = "CORS 허용 도메인 추가 요청 폼")
@Getter
@Setter
public class CorsOriginAddForm {
@Schema(description = "CORS 허용 도메인", example = "https://admin.alist.co.kr", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "CORS 허용 도메인은 필수입니다.")
@Pattern(
regexp = "^(\\*|https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?)$",
message = "CORS 허용 도메인은 http(s)://domain 또는 * 형식이어야 합니다."
)
private String allowedOrigin;
public CorsOriginAddDto toCorsOriginAddDto() {
CorsOriginAddDto corsOriginAddDto = new CorsOriginAddDto();
corsOriginAddDto.setAllowedOrigin(allowedOrigin == null ? null : allowedOrigin.trim());
return corsOriginAddDto;
}
}
@@ -0,0 +1,23 @@
package com.alist.api.modules.cors.form;
import com.alist.api.modules.cors.dto.CorsOriginDeleteDto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
@Schema(description = "CORS 허용 도메인 삭제 요청 폼")
@Getter
@Setter
public class CorsOriginDeleteForm {
@Schema(description = "CORS 허용 도메인 PK", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "CORS 허용 도메인 PK는 필수입니다.")
private Integer corsIdx;
public CorsOriginDeleteDto toCorsOriginDeleteDto() {
CorsOriginDeleteDto corsOriginDeleteDto = new CorsOriginDeleteDto();
corsOriginDeleteDto.setCorsIdx(corsIdx);
return corsOriginDeleteDto;
}
}
@@ -0,0 +1,24 @@
package com.alist.api.modules.cors.form;
import com.alist.api.common.paging.PageRequest;
import com.alist.api.modules.cors.dto.CorsOriginSearchDto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
@Schema(description = "CORS 허용 도메인 목록 검색 조건")
@Getter
@Setter
public class CorsOriginSearchForm extends PageRequest {
@Schema(description = "검색어. CORS 허용 도메인을 대상으로 검색합니다.", example = "alist.co.kr")
private String keyword;
public CorsOriginSearchDto toCorsOriginSearchDto() {
CorsOriginSearchDto corsOriginSearchDto = new CorsOriginSearchDto();
corsOriginSearchDto.setPage(getPage());
corsOriginSearchDto.setSize(getSize());
corsOriginSearchDto.setKeyword(keyword);
return corsOriginSearchDto;
}
}
@@ -0,0 +1,25 @@
package com.alist.api.modules.cors.mapper;
import com.alist.api.modules.cors.dto.CorsOriginAddDto;
import com.alist.api.modules.cors.dto.CorsOriginDeleteDto;
import com.alist.api.modules.cors.dto.CorsOriginSearchDto;
import com.alist.api.modules.cors.vo.CorsOriginCacheVo;
import com.alist.api.modules.cors.vo.CorsOriginVo;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface CorsAllowMapper {
List<CorsOriginCacheVo> selectCorsAllowedCacheList();
List<CorsOriginVo> selectCorsAllowedList(CorsOriginSearchDto corsOriginSearchDto);
int selectCorsAllowedCount(CorsOriginSearchDto corsOriginSearchDto);
int selectCorsAllowedOriginCount(String allowedOrigin);
int insertCorsAllowedOrigin(CorsOriginAddDto corsOriginAddDto);
int deleteCorsAllowedOrigin(CorsOriginDeleteDto corsOriginDeleteDto);
}
@@ -1,15 +0,0 @@
package com.alist.api.modules.cors.mapper;
import com.alist.api.modules.cors.vo.CorsOriginVo;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface CorsMapper {
/**
* 전체 CORS 허용 도메인 목록 조회 (user_token_idx별로)
* @return CorsOriginDto 리스트
*/
List<CorsOriginVo> selectCorsAllowedList();
}
@@ -0,0 +1,59 @@
package com.alist.api.modules.cors.service;
import com.alist.api.modules.cors.dto.CorsOriginAddDto;
import com.alist.api.modules.cors.dto.CorsOriginDeleteDto;
import com.alist.api.modules.cors.dto.CorsOriginSearchDto;
import com.alist.api.modules.cors.mapper.CorsAllowMapper;
import com.alist.api.modules.cors.vo.CorsOriginAddVo;
import com.alist.api.modules.cors.vo.CorsOriginDeleteVo;
import com.alist.api.modules.cors.vo.CorsOriginListVo;
import com.alist.api.modules.cors.vo.CorsOriginVo;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class CorsAllowService {
private final CorsAllowMapper corsAllowMapper;
public CorsAllowService(CorsAllowMapper corsAllowMapper) {
this.corsAllowMapper = corsAllowMapper;
}
@Transactional(readOnly = true)
public CorsOriginListVo selectCorsAllowedList(CorsOriginSearchDto corsOriginSearchDto) {
int totalCount = corsAllowMapper.selectCorsAllowedCount(corsOriginSearchDto);
List<CorsOriginVo> corsOriginList = corsAllowMapper.selectCorsAllowedList(corsOriginSearchDto);
CorsOriginListVo result = new CorsOriginListVo();
result.setCorsOriginList(corsOriginList);
result.setPaging(corsOriginSearchDto, totalCount);
return result;
}
@Transactional
public CorsOriginAddVo insertCorsAllowedOrigin(CorsOriginAddDto corsOriginAddDto) {
int duplicateCount = corsAllowMapper.selectCorsAllowedOriginCount(corsOriginAddDto.getAllowedOrigin());
CorsOriginAddVo result = new CorsOriginAddVo();
if (duplicateCount > 0) {
result.setCorsOriginAdded(false);
return result;
}
int insertCount = corsAllowMapper.insertCorsAllowedOrigin(corsOriginAddDto);
result.setCorsOriginAdded(insertCount > 0);
return result;
}
@Transactional
public CorsOriginDeleteVo deleteCorsAllowedOrigin(CorsOriginDeleteDto corsOriginDeleteDto) {
int updateCount = corsAllowMapper.deleteCorsAllowedOrigin(corsOriginDeleteDto);
CorsOriginDeleteVo result = new CorsOriginDeleteVo();
result.setCorsOriginDeleted(updateCount > 0);
return result;
}
}
@@ -0,0 +1,13 @@
package com.alist.api.modules.cors.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
@Schema(description = "CORS 허용 도메인 추가 응답")
@Getter
@Setter
public class CorsOriginAddVo {
@Schema(description = "CORS 허용 도메인 추가 여부", example = "true")
private boolean corsOriginAdded;
}
@@ -0,0 +1,13 @@
package com.alist.api.modules.cors.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
@Schema(description = "CORS 허용 도메인 캐시 항목")
@Getter
@Setter
public class CorsOriginCacheVo {
@Schema(description = "CORS 허용 도메인", example = "https://admin.alist.co.kr")
private String allowedOrigin;
}
@@ -0,0 +1,13 @@
package com.alist.api.modules.cors.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
@Schema(description = "CORS 허용 도메인 삭제 응답")
@Getter
@Setter
public class CorsOriginDeleteVo {
@Schema(description = "CORS 허용 도메인 삭제 여부", example = "true")
private boolean corsOriginDeleted;
}
@@ -0,0 +1,16 @@
package com.alist.api.modules.cors.vo;
import com.alist.api.common.paging.PageResponse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
@Schema(description = "CORS 허용 도메인 목록 조회 응답")
@Getter
@Setter
public class CorsOriginListVo extends PageResponse {
@Schema(description = "CORS 허용 도메인 목록")
private List<CorsOriginVo> corsOriginList;
}
@@ -1,10 +1,16 @@
package com.alist.api.modules.cors.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
@Schema(description = "CORS 허용 도메인 목록 항목")
@Getter
@Setter
public class CorsOriginVo {
@Schema(description = "CORS 허용 도메인 PK", example = "1")
private Integer corsIdx;
@Schema(description = "CORS 허용 도메인", example = "https://admin.alist.co.kr")
private String allowedOrigin;
}
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.alist.api.modules.cors.mapper.CorsAllowMapper">
<sql id="corsAllowedWhere">
FROM ALISTLMS.test_cors_allowed_list
WHERE del_yn = 1
AND allowed_origin IS NOT NULL
<if test="keyword != null and keyword != ''">
AND allowed_origin LIKE CONCAT('%', #{keyword}, '%')
</if>
</sql>
<select id="selectCorsAllowedList" resultType="com.alist.api.modules.cors.vo.CorsOriginVo">
/* CorsMapper.selectCorsAllowedList */
SELECT cors_idx
, allowed_origin
<include refid="corsAllowedWhere"/>
ORDER BY cors_idx DESC
LIMIT #{limit}
OFFSET #{offset}
</select>
<select id="selectCorsAllowedCount" resultType="int">
/* CorsAllowMapper.selectCorsAllowedCount */
SELECT COUNT(*)
<include refid="corsAllowedWhere"/>
</select>
<select id="selectCorsAllowedCacheList" resultType="com.alist.api.modules.cors.vo.CorsOriginCacheVo">
/* CorsAllowMapper.selectCorsAllowedCacheList */
SELECT allowed_origin
FROM ALISTLMS.test_cors_allowed_list
WHERE del_yn = 1
AND allowed_origin IS NOT NULL
ORDER BY cors_idx
</select>
<select id="selectCorsAllowedOriginCount" parameterType="string" resultType="int">
/* CorsAllowMapper.selectCorsAllowedOriginCount */
SELECT COUNT(1)
FROM ALISTLMS.test_cors_allowed_list
WHERE del_yn = 1
AND allowed_origin = #{allowedOrigin}
</select>
<insert id="insertCorsAllowedOrigin" parameterType="com.alist.api.modules.cors.dto.CorsOriginAddDto">
/* CorsAllowMapper.insertCorsAllowedOrigin */
INSERT INTO ALISTLMS.test_cors_allowed_list (
allowed_origin
, del_yn
) VALUES (
#{allowedOrigin}
, 1
)
</insert>
<update id="deleteCorsAllowedOrigin">
/* CorsAllowMapper.deleteCorsAllowedOrigin */
UPDATE ALISTLMS.test_cors_allowed_list
SET del_yn = 0
WHERE cors_idx = #{corsIdx}
AND del_yn = 1
</update>
</mapper>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.alist.api.modules.cors.mapper.CorsMapper">
<select id="selectCorsAllowedList" resultType="com.alist.api.modules.cors.vo.CorsOriginVo">
/* CorsMapper.selectCorsAllowedList */
SELECT allowed_origin
FROM ALISTLMS.test_cors_allowed_list
WHERE del_yn = 1
AND allowed_origin IS NOT NULL
ORDER BY cors_idx
</select>
</mapper>