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
@@ -0,0 +1,50 @@
package kr.ac.kookmin.stream.api.admin.event.locker;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
import kr.ac.kookmin.stream.common.CommonErrorCode;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 사물함 구역 API의 문서 명세. 구현은 {@link AdminLockerSectionController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
* <p>
* 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다.
*/
@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회")
public interface AdminLockerSectionApi {

/** 구역별 전체·선택 가능·배정 사물함 수. */
@Operation(summary = "사물함 구역 목록 조회",
description = "운영 회차를 기준으로 구역별 전체 사물함 수, 선택 가능한 사물함 수, 배정된 사물함 수를 조회한다. "
+ "사용 중지된 사물함과 그 회차에 배정된 사물함은 선택 가능 수에서 빠진다. "
+ "배정 수는 배정된 뒤 사용 중지된 사물함도 센다. 구역은 식별자 오름차순으로 반환한다.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 구역을 식별자 오름차순으로 둔 것도 좋아 보여요. 학생 목록(findAllByOrderByIdAsc)과 순서가 같고, 라벨로 정렬하면 A-10이 A-2보다 앞에 오는 문제가 생기니까요. 나중에 운영진이 순서를 바꿔야 하면 그때 display_order를 두면 될 것 같아요.

@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
ApiResponse<AdminLockerSectionListResponse> getLockerSections(
AdminApiUser apiUser,
@ParameterObject LockerPeriodParams params
);

/** 구역의 칸 배치·사진과 사물함별 상태. */
@Operation(summary = "사물함 구역 상세 조회",
description = "운영 회차를 기준으로 구역의 칸 배치 구조(layout), 구역 사진, 사물함별 상태를 조회한다. "
+ "layout은 저장된 원문을 그대로 돌려주며 칸 번호는 lockers[].lockerNumber와 같다. "
+ "배치 구조를 아직 등록하지 않은 구역이면 layout이 null이고 photos가 빈 배열이다. "
+ "사물함 상태는 물리 상태와 그 회차의 배정 여부를 합친 값이고, 배정된 뒤 사용 중지된 칸은 ASSIGNED다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
ApiResponse<AdminLockerSectionDetailResponse> getLockerSection(
AdminApiUser apiUser,
Long sectionId,
@ParameterObject LockerPeriodParams params
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package kr.ac.kookmin.stream.api.admin.event.locker;

import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.Set;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
import kr.ac.kookmin.stream.file.domain.File;
import kr.ac.kookmin.stream.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/lockers/sections")
@RequiredArgsConstructor
public class AdminLockerSectionController implements AdminLockerSectionApi {

private final LockerService lockerService;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[r] 운영진 조회가 학생 앱용 LockerService를 그대로 쓰고 있는데, 최근 coding-style.md 2-7절에 "운영진 전용 기능은 Admin{Domain}Service로 분리하고, 두 서비스는 서로 호출하지 않는다"가 들어가서 AdminLockerService 쪽으로 옮기면 좋을 것 같아요.

규칙 말고도 동작 차이가 하나 있어요. getSections·getSectionDetail은 입구에서 requirePublishedPeriod로 게시된 회차만 통과시키는데, 운영진 회차 API(#129)는 미게시 회차도 보여줘서, 운영진이 목록에서 미게시 회차를 고르면 구역 API가 LOCKER_PERIOD_NOT_FOUND를 받게 돼요. API로 만든 회차는 항상 게시 상태라 당장은 드물겠지만, V9 기본값이 is_published = 0이라 DB로 직접 넣은 회차는 미게시일 수 있어서요.

구역별 개수를 세는 로직은 LockerSectionSummary 쪽 정적 팩토리로 옮기면 두 서비스가 서로 호출하지 않고도 같이 쓸 수 있을 것 같아요. 그러면 #148에서 이 컨트롤러가 LockerService와 AdminLockerService를 함께 주입하는 것도 하나로 정리될 것 같습니다.

private final FileService fileService;

@Override
@GetMapping
public ApiResponse<AdminLockerSectionListResponse> getLockerSections(
AdminApiUser apiUser,
@Valid @ModelAttribute LockerPeriodParams params
) {
List<LockerSectionSummary> sections = lockerService.getSections(params.lockerPeriodId());
return ApiResponse.success(AdminLockerSectionListResponse.from(sections));
}

@Override
@GetMapping("/{sectionId}")
public ApiResponse<AdminLockerSectionDetailResponse> getLockerSection(
AdminApiUser apiUser,
@PathVariable Long sectionId,
@Valid @ModelAttribute LockerPeriodParams params
) {
Long lockerPeriodId = params.lockerPeriodId();
LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId);
// 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다
Map<Long, File> photosById = fileService.findAllByIdIn(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] "layout이 없으면 사진도 없다" 처리가 여기와 AppLockerController에 반복되고, 지워진 사진을 거르는 방식도 응답마다 달라요(관리자는 filter(photosById::containsKey), 앱은 filter(Objects::nonNull)). LockerSectionDetail에 photoFileIds()(layout이 없으면 빈 목록)를 두면 호출부가 단순해지고 거르는 방식도 하나로 맞출 수 있을 것 같아요.

detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds());
Set<Long> appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);

return ApiResponse.success(
AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import jakarta.validation.constraints.NotNull;

/**
* 구역 조회의 기준 회차. 구역은 운영 회차에 묶여 있지 않고, 회차는 "어느 회차의 배정 현황을 볼지"를 정한다.
* <p>
* 필드가 하나뿐이지만 {@code @RequestParam}으로 받지 않는다. {@code GlobalExceptionHandler}가
* {@code MissingServletRequestParameterException}을 다루지 않아 값이 빠지면 500이 되고,
* {@code @Valid @ModelAttribute}여야 400 {@code INVALID_INPUT}으로 나간다.
*
* @param lockerPeriodId 조회할 사물함 운영 회차 식별자
*/
public record LockerPeriodParams(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 리뷰 포인트 주신 LockerPeriodParams 복제는 지금처럼 Params + @ModelAttribute로 두는 게 coding-style.md 2-2절에도 맞아서 그대로 좋을 것 같아요. GlobalExceptionHandler 보강은 별도 이슈로 가는 데 동의하고, 그때 @PathVariable Long sectionId에 abc 같은 값이 들어와 MethodArgumentTypeMismatchException으로 500이 나는 것도 같이 다루면 좋을 것 같아요.


@NotNull(message = "사물함 운영 회차를 입력해 주세요.")
Long lockerPeriodId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package kr.ac.kookmin.stream.api.admin.event.locker.response;

import com.fasterxml.jackson.annotation.JsonRawValue;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Map;
import java.util.Set;
import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
import kr.ac.kookmin.stream.file.domain.File;

/**
* @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null}
* @param photos 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다
*/
public record AdminLockerSectionDetailResponse(
Long sectionId,
String section,
Layout layout,
List<Photo> photos,
List<LockerItem> lockers
) {

/**
* @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다
* @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
*/
public static AdminLockerSectionDetailResponse of(
LockerSectionDetail detail,
Map<Long, File> photosById,
Set<Long> appliedLockerIds
) {
return new AdminLockerSectionDetailResponse(
detail.section().getId(),
detail.section().getLabel(),
detail.layout() == null ? null : Layout.from(detail.layout()),
toPhotos(detail, photosById),
detail.lockers().stream()
.map(locker -> LockerItem.from(locker, appliedLockerIds.contains(locker.getId())))
.toList()
);
}

/** 배치 구조를 등록하지 않은 구역이면 사진도 없다. */
private static List<Photo> toPhotos(LockerSectionDetail detail, Map<Long, File> photosById) {
if (detail.layout() == null) {
return List.of();
}
return detail.layout().getPhotoFileIds().stream()
.filter(photosById::containsKey)
.map(fileId -> Photo.from(fileId, photosById.get(fileId)))
.toList();
}

/**
* @param version layout 형식 버전
* @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
*/
public record Layout(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 리뷰 포인트 주신 Layout 중복은 지금처럼 복제해 두는 데 동의해요. 12줄을 공유하자고 common-api가 event 도메인에 의존하게 하는 건 손해 같고, 앱 쪽은 "모르는 버전이면 업데이트 안내"처럼 의미가 이미 갈려 있어서 나중에 쓰기 API가 생기면 더 달라질 것 같아요.

int version,
@JsonRawValue
@Schema(type = "object", description = "root 블록 트리. 칸 번호는 lockers[].lockerNumber와 같다")
String root
) {

public static Layout from(LockerSectionLayout layout) {
return new Layout(layout.getVersion(), layout.getLayout());
}
}

/** 운영진은 사진을 지우거나 순서를 바꿀 때 파일 식별자가 필요하므로 URL과 함께 내려준다. */
public record Photo(Long fileId, String fileUrl) {

public static Photo from(Long fileId, File file) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 값 두 개를 받는 팩토리라 coding-style.md 2-10절 기준으로는 of가 맞을 것 같아요. 공지·행사·아카이브 응답의 Image.of(fileId, file)와도 맞춰지고요. 아래 LockerItem.from(Locker, boolean)도 같은 경우예요.

return new Photo(fileId, StorageUrlBuilder.build(file.getFileKey()));
}
}

/** 도메인 {@link Locker}와 이름이 겹치지 않도록 {@code LockerItem}으로 둔다. */
public record LockerItem(
Long lockerId,
int lockerNumber,
String lockerLabel,
LockerDisplayStatus status
) {

/** @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 */
public static LockerItem from(Locker locker, boolean applied) {
return new LockerItem(
locker.getId(),
locker.getLockerNumber(),
locker.getLockerLabel(),
LockerDisplayStatus.from(locker.getStatus(), applied)
);
}
}

/**
* 운영진 화면에 칸을 어떻게 그릴지. 저장되는 물리 상태({@link LockerStatus})와 회차 배정 여부를 합친 값이라
* 이 응답에서만 쓰고 도메인으로 올리지 않는다({@code coding-style.md} 2-2절).
*/
public enum LockerDisplayStatus {

AVAILABLE,
DISABLED,
ASSIGNED;

/**
* 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진이 그 학생에게 다른 자리를 줘야 하는 상황이라
* 배정 사실이 물리 상태보다 먼저 보여야 한다.
*/
public static LockerDisplayStatus from(LockerStatus status, boolean applied) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[c] 리뷰 포인트 주신 "배정된 뒤 사용 중지된 칸을 ASSIGNED로 본 판단"에 대한 의견이에요. 배정 사실을 먼저 보여주려는 의도는 좋은데, 하나로 합쳐 버리면 그 칸이 사용 중지 상태인지 화면에서 알 수 없어서, 정작 "다른 자리를 줘야 하는 칸"을 찾기 어려울 것 같아요. 물리 상태와 배정 여부를 나눠서 내려주면 화면이 둘 다 표시할 수 있고, #148 요청 필드(lockerStatus)와도 이름이 맞춰질 것 같아요.

public record LockerItem(Long lockerId, int lockerNumber, String lockerLabel, LockerStatus lockerStatus, boolean assigned)

그리고 #148에서는 이용 중인 칸을 DISABLED로 바꾸지 못하게 막고 있어서, 이 주석이 전제하는 "배정 후 고장 난 칸"이 API로는 생기지 않는 상태예요. 두 PR 중 어느 쪽으로 맞출지 같이 정해주시면 좋겠습니다!

if (applied) {
return ASSIGNED;
}
return status == LockerStatus.AVAILABLE ? AVAILABLE : DISABLED;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 여기서는 AVAILABLE을 status == LockerStatus.AVAILABLE로 따로 판정하는데, 목록의 availableCount는 Locker.isSelectable로 세고 있어요. isSelectable Javadoc에도 "목록 수와 상세 여부가 같은 기준을 써야 하므로 도메인에 둔다"고 되어 있어서, 상태가 늘거나 판정이 바뀌면 둘이 어긋날 수 있을 것 같아요. 위 코멘트처럼 필드를 나누면 자연스럽게 없어지는 부분이고, enum을 유지하신다면 Locker를 받아서 이렇게 맞추면 될 것 같아요.

if (applied) {
    return ASSIGNED;
}
return locker.isSelectable(false) ? AVAILABLE : DISABLED;

}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package kr.ac.kookmin.stream.api.admin.event.locker.response;

import java.util.List;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;

public record AdminLockerSectionListResponse(List<Section> sections) {

public static AdminLockerSectionListResponse from(List<LockerSectionSummary> summaries) {
return new AdminLockerSectionListResponse(summaries.stream()
.map(Section::from)
.toList());
}

/**
* @param totalCount 구역 내 전체 사물함 수. 사용 중지된 사물함도 포함한다
* @param availableCount 선택 가능한 사물함 수. 사용 중지된 사물함과 이 회차에 배정된 사물함은 빠진다
* @param assignedCount 이 회차에 배정된 사물함 수. 배정된 뒤 사용 중지된 사물함도 센다
*/
public record Section(
Long sectionId,
String section,
int totalCount,
int availableCount,
int assignedCount
) {

public static Section from(LockerSectionSummary summary) {
return new Section(
summary.sectionId(),
summary.label(),
summary.totalCount(),
summary.availableCount(),
summary.assignedCount()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ ApiResponse<LockerSectionListResponse> getSections(
@Operation(summary = "사물함 구역 상세 조회",
description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. "
+ "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 "
+ "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 "
+ "선택할 수 있다.")
+ "layout이 null이고 photoUrls가 빈 배열이다. 사진은 노출 순서대로 담기며 삭제된 파일은 빠진다. "
+ "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
ApiResponse<LockerSectionDetailResponse> getSectionLockers(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import jakarta.validation.Valid;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Map;
import java.util.Set;
import kr.ac.kookmin.stream.api.app.AppApiUser;
import kr.ac.kookmin.stream.api.app.event.locker.request.LockerApplyRequest;
Expand Down Expand Up @@ -70,15 +70,14 @@ public ApiResponse<LockerSectionDetailResponse> getSectionLockers(
Long lockerPeriodId = params.lockerPeriodId();
LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId);
// 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다
File photo = Optional.ofNullable(detail.layout())
.flatMap(layout -> fileService.findById(layout.getPhotoFileId()))
.orElse(null);
Map<Long, File> photosById = fileService.findAllByIdIn(
detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds());
Set<Long> appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
.map(Locker::getId)
.orElse(null);

return ApiResponse.success(LockerSectionDetailResponse.of(detail, photo, appliedLockerIds, myLockerId));
return ApiResponse.success(LockerSectionDetailResponse.of(detail, photosById, appliedLockerIds, myLockerId));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,61 @@
import com.fasterxml.jackson.annotation.JsonRawValue;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
import kr.ac.kookmin.stream.file.domain.File;

/**
* @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null}
* @param photoUrl 구역 실제 사진. 배치 구조를 아직 등록하지 않았거나 사진 파일이 삭제됐으면 {@code null}
* @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null}
* @param photoUrls 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다
*/
public record LockerSectionDetailResponse(
Long sectionId,
String section,
Layout layout,
String photoUrl,
List<String> photoUrls,
List<LockerResponse> lockers
) {

/**
* @param photo 구역 사진 파일. 배치 구조를 아직 등록하지 않았거나 파일이 삭제됐으면 {@code null}
* @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다
* @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
* @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
*/
public static LockerSectionDetailResponse of(
LockerSectionDetail detail,
File photo,
Map<Long, File> photosById,
Set<Long> appliedLockerIds,
Long myLockerId
) {
return new LockerSectionDetailResponse(
detail.section().getId(),
detail.section().getLabel(),
detail.layout() == null ? null : Layout.from(detail.layout()),
StorageUrlBuilder.build(photo),
toPhotoUrls(detail, photosById),
detail.lockers().stream()
.map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId))
.toList()
);
}

/**
* 배치 구조를 등록하지 않은 구역이면 사진도 없다. 학생 앱은 사진을 보여주기만 하므로 파일 식별자 없이 URL만 담는다.
*/
private static List<String> toPhotoUrls(LockerSectionDetail detail, Map<Long, File> photosById) {
if (detail.layout() == null) {
return List.of();
}
return detail.layout().getPhotoFileIds().stream()
.map(fileId -> StorageUrlBuilder.build(fileId, photosById))
.filter(Objects::nonNull)
.toList();
}

/**
* @param version layout 형식 버전. 앱은 모르는 버전이면 그리지 않고 업데이트를 안내한다
* @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
Expand Down
Loading