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 { CONTENT_TYPE_CODE } from '@/lib/domain/content';
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`)을 쓰고
* `CNTNTS_TYPE`으로 갈린다(`V` 쇼츠 / `T` 만화).
*
* ```
* GET /api/v1/mngr/cntntns/pagination searchCntntsType 필수
* GET /api/v1/mngr/cntntns/{cntntsSn} 단건
* POST /api/v1/mngr/cntntns @RequestBody(JSON)
* PUT /api/v1/mngr/cntntns/{cntntsSn} @RequestBody(JSON)
* DELETE /api/v1/mngr/cntntns/{cntntsSn} soft delete(DEL_YN)
* ```
*
* 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다.
*
* ⚠️ 백엔드 결함(보고함, 미수정) — 그래서 목록은 전량 수령 후 로컬 검색·정렬·페이징이다.
* 1. 검색 조건(`c.CNTNTS_TITLE`·`k.CNTNTS_KEYWORD`)이 별칭을 쓰는데 `selectPaginationCount`는
* `FROM TB_COM_CNTNTS`뿐이라 검색어를 실으면 count가 SQL 오류로 죽는다.
* 2. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시 뒤집어
* **옛 글부터** 나간다. 새로 등록한 글이 첫 페이지가 아니라 마지막 페이지에 붙는다.
* 3. 키워드·학교급·파일 LEFT JOIN으로 한 콘텐츠가 여러 행이 되고 LIMIT이 그 행 기준이라, 페이지
* 경계에서 잘린 콘텐츠는 키워드·컷 목록이 반쪽으로 온다.
* 4. 학교·학년 파라미터는 `<if>` 본문이 값 대신 `#{searchKeyword}`를 비교해 보내도 의미가 없다.
*/
const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns';
type ContentApiKind = 'shorts' | 'comic';
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 toSchoolGrades(raw: unknown): { schoolCode: string; grades: string[] } {
if (!Array.isArray(raw)) {
return { schoolCode: '', grades: [] };
}
const grades: string[] = [];
let schoolCode = '';
for (const entry of raw) {
if (!isRecord(entry)) {
continue;
}
schoolCode = schoolCode || (readString(entry, 'schulCd') ?? '');
const grade = readString(entry, 'grdCd');
if (grade && !grades.includes(grade)) {
grades.push(grade);
}
}
return { schoolCode, grades };
}
/** 한 `atchFileId` 아래 여러 컷이 `fileSn`으로 들어온다. */
function toFileNumbers(raw: unknown): number[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.flatMap((entry) => (isRecord(entry) ? [Number(entry.fileSn)] : []))
.filter((value) => Number.isInteger(value) && value > 0)
.sort((a, b) => a - b);
}
function toContentItem(raw: unknown): ContentItem {
if (!isRecord(raw)) {
throw new Error('콘텐츠 응답 항목의 형식이 올바르지 않습니다.');
}
const cntntsSn = readString(raw, 'cntntsSn');
if (!cntntsSn) {
throw new Error('콘텐츠 응답에 cntntsSn이 없습니다.');
}
const schulGrd = toSchoolGrades(raw.schulGrdList);
return {
cntntsSn,
cutFileNumbers: toFileNumbers(raw.fileList),
name: readString(raw, 'cntntsNm') ?? '',
title: readString(raw, 'cntntsTitle') ?? '',
subject: readString(raw, 'cntntsSbjct') ?? '',
message: readString(raw, 'cntntsMssage') ?? '',
schoolCode: schulGrd.schoolCode,
grades: schulGrd.grades,
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(
kind: ContentApiKind,
pageIndex: number,
recordCountPerPage: number
): Record<string, string | number> {
return {
// 없으면 `CNTNTS_TYPE = NULL`이 되어 한 건도 오지 않는다.
// 검색어는 보내지 않는다 — 검색 조건이 c.·k. 별칭을 쓰는데 count 쿼리에는 별칭이 없어
// 키워드를 실으면 count가 SQL 오류로 죽는다(파일 상단 ⚠️ 1). 검색은 아래에서 로컬로 건다.
searchCntntsType: CONTENT_TYPE_CODE[kind],
pageIndex,
recordCountPerPage,
};
}
async function requestList(
kind: ContentApiKind,
pageIndex: number,
recordCountPerPage: number
): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${CONTENT_BASE_PATH}/pagination`, {
method: 'GET',
query: buildListParams(kind, 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;
};
// TODO(백엔드): 목록 정렬·LIMIT이 고쳐지면 서버 페이징으로 되돌린다. 그전까지 이 상한을
// 넘는 콘텐츠는 목록에서 조용히 잘린다.
const FETCH_LIMIT = 500;
function matchesContentQuery(item: ContentItem, query: ContentQuery): boolean {
if (query.school && item.schoolCode !== query.school) {
return false;
}
if (query.grade && !item.grades.includes(query.grade)) {
return false;
}
const keyword = query.keyword.trim().toLowerCase();
if (!keyword) {
return true;
}
if (query.searchField === 'keyword') {
return item.keywords.some((entry) =>
entry.keyword.toLowerCase().includes(keyword)
);
}
// title(쇼츠)·name(만화) 모두 같은 컬럼(CNTNTS_NM=CNTNTS_TITLE)이라 한 값을 본다.
return item.title.toLowerCase().includes(keyword);
}
export async function fetchContents(
kind: ContentApiKind,
query: ContentQuery
): Promise<ContentPage> {
// 서버 페이징을 쓰지 않는다 — 목록 SQL이 rnum(등록일 내림차순)을 다시 뒤집어 옛 글부터
// 내보내고, LIMIT도 조인으로 불어난 행 기준이라 새 글이 마지막 페이지로 밀린다(⚠️ 2·3).
const { items } = await requestList(kind, 1, FETCH_LIMIT);
const matched = items
.filter((item) => matchesContentQuery(item, query))
// rnum은 resultMap에 없고 등록일은 일 단위라, 새 글이 먼저 오도록 일련번호로 세운다.
.sort((a, b) => Number(b.cntntsSn) - Number(a.cntntsSn));
const offset = (query.page - 1) * query.pageSize;
return {
items: matched.slice(offset, offset + query.pageSize),
totalCount: matched.length,
isTotalCountExact: true,
};
}
/** 단건 조회 — 목록과 같은 SQL이라 키워드·학교급·파일 목록이 함께 온다. */
export async function fetchContentDetail(
kind: ContentApiKind,
cntntsSn: string
): Promise<ContentItem | null> {
void kind;
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsSn)}`,
{
method: 'GET',
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
}
);
if (!result.ok) {
throw new BackendRequestError(result);
}
if (result.data === null || result.data === undefined) {
return null;
}
return toContentItem(result.data);
}
// 이슈: 주제·전달메시지를 담을 컬럼이 테이블에 없다. 화면은 기획대로 두고 값은 보내지 않는다.
type ContentWritePayload = {
cntntsType?: string;
cntntsNm: string;
cntntsTitle: string;
atchFileId: string;
thumbAtchFileId: string;
useYn: string;
keywordList: Array<{ cntntsKeyword: string; cntntsExpln: string }>;
schulGrdList: Array<{ schulCd: string; grdCd: string | null }>;
};
function toKeywordPayload(keywords: readonly ContentKeyword[]) {
return keywords.map((item) => ({
cntntsKeyword: item.keyword,
cntntsExpln: item.description,
}));
}
/** 학년마다 한 줄이다. 학년을 고르지 않았으면 학교만 담은 한 줄을 남긴다. */
function toSchoolGradePayload(schoolCode: string, grades: readonly string[]) {
if (!schoolCode) {
return [];
}
if (grades.length === 0) {
// 학년 없는 학교(전체연령 등)는 학교만 남긴다 — 빈 문자열을 넣으면 GRD_CD에 ''가 박힌다.
return [{ schulCd: schoolCode, grdCd: null }];
}
return grades.map((grade) => ({ schulCd: schoolCode, grdCd: grade }));
}
export function buildShortsPayload(
values: ShortsFormValues
): ContentWritePayload {
return {
cntntsNm: values.title,
cntntsTitle: values.title,
atchFileId: values.videoFileId,
thumbAtchFileId: values.thumbnailFileId,
useYn: values.isVisible ? 'Y' : 'N',
keywordList: toKeywordPayload(values.keywords),
schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
};
}
export function buildComicPayload(values: ComicFormValues): ContentWritePayload {
return {
cntntsNm: values.name,
cntntsTitle: values.name,
// 컷은 한 첨부 아래 fileSn 1..N으로 들어간다 — 배열 값이 모두 같은 첨부 아이디다.
atchFileId: values.cutFileIds[0] ?? '',
thumbAtchFileId: values.thumbnailFileId,
useYn: values.isVisible ? 'Y' : 'N',
keywordList: toKeywordPayload(values.keywords),
schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
};
}
/**
* 실패한 쓰기의 사유와 보낸 본문을 남긴다. 요청 자체는 테스트 환경에서 backend-fetch가
* 전부 찍으므로(중앙 로거) 여기는 실패만 맡는다 — 운영에서도 원인 추적에 이게 유일한 단서다.
*/
function logWriteFailure(
method: string,
path: string,
body: unknown,
failure: { code: number; message: string }
): void {
const lines = [
`[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`,
body === undefined ? null : `body(JSON):\n${JSON.stringify(body, null, 2)}`,
].filter(Boolean);
console.error(lines.join('\n'));
}
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) {
logWriteFailure(method, path, body, result);
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(CONTENT_BASE_PATH, 'POST', { ...payload, cntntsType });
}
export async function updateContent(
kind: ContentApiKind,
cntntsSn: string,
payload: ContentWritePayload
): Promise<void> {
await sendWrite(
`${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsSn)}`,
'PUT',
// 수정 SQL이 `CNTNTS_TYPE = #{cntntsType}`을 조건 없이 덮는다. 빼고 보내면 종류가 지워져
// 목록(`CNTNTS_TYPE = 'V'|'T'`)에서 사라진다.
{ ...payload, cntntsType: CONTENT_TYPE_CODE[kind] }
);
}
export async function deleteContent(
kind: ContentApiKind,
cntntsSn: string
): Promise<void> {
await sendWrite(`${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsSn)}`, 'DELETE');
}
export function contentThumbnailUrl(item: ContentItem): string | null {
return buildFileImageUrl(item.thumbnailFileId);
}
/** 컷 이미지 주소 — 응답이 준 파일 번호를 쓰고, 없으면 1..count로 채운다. */
export function contentCutUrls(item: ContentItem, count: number): string[] {
const numbers =
item.cutFileNumbers.length > 0
? item.cutFileNumbers
: Array.from({ length: count }, (_, index) => index + 1);
return numbers
.map((fileSn) => buildFileImageUrl(item.attachmentId, fileSn))
.filter((url): url is string => url !== null);
}