File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
import type { ContentItem, ContentKeyword } from '@/lib/domain/content';
import type { ContentQuery } from '@/lib/domain/content-query';
import type {
ComicFormValues,
ShortsFormValues,
} from '@/lib/domain/content-form';
/**
* 금융쇼츠·4컷금융만화 Repository — 두 화면은 같은 테이블(`TB_COM_CNTNTS`)을 쓰고 경로만
* 갈린다. 금융OX퀴즈는 테이블도 API도 달라 `content-quiz-repository.ts`가 따로 맡는다.
*
* ```
* GET /api/v1/mngr/cntntns/toon/pagination 4컷금융만화 목록
* GET /api/v1/mngr/cntntns/video/pagination 금융쇼츠 목록
* ```
*
* 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다.
* 이슈: `video/pagination` 컨트롤러가 `return null;`이라 응답 봉투 없이 **본문이 통째로 빈**
* 200이 온다(서비스가 주입돼 있지 않다). 그래서 목록 조회는 `canHaveEmptyBody`가 필요하고,
* 금융쇼츠 목록은 늘 빈 목록으로 보인다.
* 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약
* (`POST {base}` / `PUT {base}/{sn}` / `DELETE {base}/{sn}`)으로 미리 맞춰 둔 것이라
* 백엔드가 생기기 전에는 실패한다.
* 이슈: 학교(`COM_SCHUL_CD`)·학년(`GRD_CD`)을 담을 컬럼도 필터 SQL도 없다 — 검색 VO에 파라미터만
* 있고 매퍼의 `<if>`가 비어 있다. 주제·전달메시지도 컬럼이 없어 목록·상세에서 늘 빈 값이다.
* 이슈: toon 매퍼의 검색 조건이 금칙어 테이블 컬럼(`BEN_WORD_NM`)을 보고 있어 검색어가 걸리면
* SQL이 깨진다. 그래서 검색어는 보내되 백엔드 수정 전까지는 결과를 신뢰할 수 없다.
* 이슈: 목록 SQL이 키워드를 LEFT JOIN해 행이 키워드 수만큼 늘어나는데 count는 조인 없이 센다.
* 한 페이지에 실제로 오는 콘텐츠 수가 `recordCountPerPage`보다 적을 수 있다.
*/
const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns';
const SEGMENT: Record<'shorts' | 'comic', string> = {
shorts: 'video',
comic: 'toon',
};
type ContentApiKind = keyof typeof SEGMENT;
/** 단건 조회 API가 없어 목록에서 찾는다. 그때 한 번에 받아올 최대 행 수. */
const DETAIL_SCAN_LIMIT = 500;
function basePath(kind: ContentApiKind): string {
return `${CONTENT_BASE_PATH}/${SEGMENT[kind]}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
function toKeywords(raw: unknown): ContentKeyword[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const keyword = readString(entry, 'cntntsKeyword');
if (!keyword) {
return [];
}
return [{ keyword, description: readString(entry, 'cntntsExpln') ?? '' }];
});
}
function toContentItem(raw: unknown): ContentItem {
if (!isRecord(raw)) {
throw new Error('콘텐츠 응답 항목의 형식이 올바르지 않습니다.');
}
const cntntsSn = readString(raw, 'cntntsSn');
if (!cntntsSn) {
throw new Error('콘텐츠 응답에 cntntsSn이 없습니다.');
}
return {
cntntsSn,
name: readString(raw, 'cntntsNm') ?? '',
title: readString(raw, 'cntntsTitle') ?? '',
subject: readString(raw, 'cntntsSbjct') ?? '',
message: readString(raw, 'cntntsMssage') ?? '',
schoolCode: readString(raw, 'schulCd') ?? '',
grades: (readString(raw, 'gradeCd') ?? '')
.split(',')
.map((grade) => grade.trim())
.filter(Boolean),
keywords: toKeywords(raw.keywordList),
attachmentId: readString(raw, 'atchFileId'),
thumbnailFileId: readString(raw, 'thumbAtchFileId'),
isVisible: readString(raw, 'useYn') !== 'N',
modifiedAt:
readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'),
};
}
function buildListParams(
query: ContentQuery,
pageIndex: number,
recordCountPerPage: number
): Record<string, string | number> {
const keyword = query.keyword.trim();
return {
searchSchulGradeCd: query.school,
searchGradeCd: query.grade,
searchCondition: keyword ? '1' : '',
searchKeyword: keyword,
// 이슈: 백엔드가 정렬 파라미터를 읽지 않는다(등록일 내림차순 고정). 화면 선택지는 기획대로
// 유지하고 값만 미리 실어 보낸다.
searchSortOrder: query.sort,
pageIndex,
recordCountPerPage,
};
}
async function requestList(
kind: ContentApiKind,
query: ContentQuery,
pageIndex: number,
recordCountPerPage: number
): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${basePath(kind)}/pagination`, {
method: 'GET',
query: buildListParams(query, pageIndex, recordCountPerPage),
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
const data = result.data;
// 금융쇼츠는 컨트롤러가 null을 돌려준다(파일 상단 이슈) — 빈 목록으로 다룬다.
if (data === null || data === undefined) {
return { items: [], reportedTotalCount: 0 };
}
if (!isRecord(data) || !Array.isArray(data.list)) {
throw new Error('콘텐츠 목록 응답의 형식이 올바르지 않습니다.');
}
return {
items: data.list.map(toContentItem),
reportedTotalCount:
typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
};
}
export type ContentPage = {
items: ContentItem[];
totalCount: number;
isTotalCountExact: boolean;
};
export async function fetchContents(
kind: ContentApiKind,
query: ContentQuery
): Promise<ContentPage> {
const { items, reportedTotalCount } = await requestList(
kind,
query,
query.page,
query.pageSize
);
const confirmedCount = (query.page - 1) * query.pageSize + items.length;
return {
items,
// 백엔드 `PaginationUtil`이 count 없이 페이지 행 수를 총건수로 내려주는 결함이 있어
// 하한값으로 보정한다(다른 목록 화면과 같은 처리).
totalCount: Math.max(reportedTotalCount, confirmedCount),
isTotalCountExact:
items.length < query.pageSize || reportedTotalCount > confirmedCount,
};
}
/**
* 단건 조회.
*
* TODO: `GET {base}/{cntntsSn}`이 생기면 이 스캔을 그 호출로 교체한다. 지금은 단건 API가 없어
* 목록에서 찾는다 — 목록이 돌려주는 필드만 채워지고 나머지는 빈 값이다.
*/
export async function fetchContentDetail(
kind: ContentApiKind,
cntntsSn: string
): Promise<ContentItem | null> {
const { items } = await requestList(
kind,
{
school: '',
grade: '',
level: '',
searchField: '',
keyword: '',
sort: 'created',
page: 1,
pageSize: 10,
},
1,
DETAIL_SCAN_LIMIT
);
return items.find((item) => item.cntntsSn === cntntsSn) ?? null;
}
type ContentWritePayload = {
cntntsType?: string;
cntntsNm: string;
cntntsTitle: string;
cntntsSbjct: string;
cntntsMssage: string;
schulCd: string;
gradeCd: string;
atchFileId: string;
thumbAtchFileId: string;
useYn: string;
keywordList: Array<{ cntntsKeyword: string; cntntsExpln: string }>;
};
function toKeywordPayload(keywords: readonly ContentKeyword[]) {
return keywords.map((item) => ({
cntntsKeyword: item.keyword,
cntntsExpln: item.description,
}));
}
export function buildShortsPayload(
values: ShortsFormValues
): ContentWritePayload {
return {
cntntsNm: values.title,
cntntsTitle: values.title,
cntntsSbjct: values.subject,
cntntsMssage: values.message,
schulCd: values.schoolCode,
gradeCd: values.grades.join(','),
atchFileId: values.videoFileId,
thumbAtchFileId: values.thumbnailFileId,
useYn: values.isVisible ? 'Y' : 'N',
keywordList: toKeywordPayload(values.keywords),
};
}
export function buildComicPayload(values: ComicFormValues): ContentWritePayload {
return {
cntntsNm: values.name,
cntntsTitle: values.name,
cntntsSbjct: values.subject,
cntntsMssage: values.message,
schulCd: values.schoolCode,
gradeCd: values.grades.join(','),
// 이슈: 기획은 "각 컷을 개별 이미지로 업로드"인데 백엔드 컬럼은 `atchFileId` 한 칸뿐이라
// 컷 묶음을 여러 개 연결할 자리가 없다. 지금은 첫 묶음만 저장된다.
atchFileId: values.cutFileIds[0] ?? '',
thumbAtchFileId: values.thumbnailFileId,
useYn: values.isVisible ? 'Y' : 'N',
keywordList: toKeywordPayload(values.keywords),
};
}
async function sendWrite(
path: string,
method: 'POST' | 'PUT' | 'DELETE',
body?: unknown
): Promise<string | null> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(path, {
method,
...(body === undefined ? {} : { body }),
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
return typeof result.data === 'string' ? result.data : null;
}
export async function createContent(
kind: ContentApiKind,
payload: ContentWritePayload,
cntntsType: string
): Promise<string | null> {
return sendWrite(basePath(kind), 'POST', { ...payload, cntntsType });
}
export async function updateContent(
kind: ContentApiKind,
cntntsSn: string,
payload: ContentWritePayload
): Promise<void> {
await sendWrite(
`${basePath(kind)}/${encodeURIComponent(cntntsSn)}`,
'PUT',
payload
);
}
export async function deleteContent(
kind: ContentApiKind,
cntntsSn: string
): Promise<void> {
await sendWrite(`${basePath(kind)}/${encodeURIComponent(cntntsSn)}`, 'DELETE');
}
export function contentThumbnailUrl(item: ContentItem): string | null {
return buildFileImageUrl(item.thumbnailFileId);
}
export function contentCutUrls(item: ContentItem, count: number): string[] {
return Array.from({ length: count }, (_, index) =>
buildFileImageUrl(item.attachmentId, index + 1)
).filter((url): url is string => url !== null);
}