File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
import 'server-only';
import { cache } from 'react';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import type { BannedWord } from '@/lib/domain/banned-word';
import type { BannedWordQuery } from '@/lib/domain/banned-word-query';
import type { ExcelUploadResult } from '@/lib/domain/banned-word-form';
/**
* 금칙어 Repository — `TB_SYS_BEN_WORD`.
*
* ```
* GET /api/v1/mngr/benWord/pagination searchCondition=1 + searchKeyword로 부분검색
* POST /api/v1/mngr/benWord 어노테이션 없음 → form. 중복이면 실패 응답
* DELETE /api/v1/mngr/benWord/{benWordSn} soft delete(DEL_YN)
* POST /api/v1/mngr/benWord/excel multipart, 파트 이름 `file`
* ```
*
* ⚠️ 백엔드 결함(보고함, 미수정)
* 1. 목록 정렬이 기획과 반대다. `ROW_NUMBER() OVER (ORDER BY frst_reg_dt DESC)`로 매긴 rnum을
* 다시 `ORDER BY RNUM DESC`로 뒤집어 **오래된 것이 먼저** 온다. 기획은 최신순이다. 그래서
* 페이징을 백엔드에 맡기지 않고 한 번에 받아 여기서 정렬·절단한다(rnum 1 = 최신).
* 2. `DELETE`의 인자에 `@PathVariable`이 없다. 경로만 보내면 benWordSn이 null이라 아무것도
* 지워지지 않는다. 경로와 쿼리에 같은 값을 함께 실어 보낸다 — 어노테이션이 붙어도 그대로
* 동작한다.
*/
const BEN_WORD_PATH = '/api/v1/mngr/benWord';
// TODO(백엔드): 정렬이 고쳐지면 서버 페이징으로 되돌린다. 그전까지는 이 상한을 넘는 금칙어가
// 생기면 목록이 조용히 잘린다.
const FETCH_LIMIT = 1000;
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 toBannedWord(raw: unknown): BannedWord[] {
if (!isRecord(raw)) {
return [];
}
const id = Number(raw.benWordSn);
if (!Number.isInteger(id)) {
return [];
}
return [
{
id,
word: readString(raw, 'benWordNm') ?? '',
createdAt: readString(raw, 'frstRegDtStr'),
},
];
}
/** rnum 1이 최신이라 오름차순이 곧 최신순이다. */
function byNewestFirst(raw: unknown): number {
return isRecord(raw) ? Number(raw.rnum ?? 0) : 0;
}
const fetchAllBannedWords = cache(
async (keyword: string): Promise<BannedWord[]> => {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/pagination`, {
method: 'GET',
query: {
pageIndex: 1,
recordCountPerPage: FETCH_LIMIT,
...(keyword ? { searchCondition: '1', searchKeyword: keyword } : {}),
},
accessToken: accessToken ?? undefined,
cache: 'no-store',
});
if (!result.ok) {
throw new BackendRequestError(result);
}
const data = result.data;
if (!isRecord(data) || !Array.isArray(data.list)) {
throw new Error('금칙어 목록 응답의 형식이 올바르지 않습니다.');
}
return [...data.list]
.sort((a, b) => byNewestFirst(a) - byNewestFirst(b))
.flatMap(toBannedWord);
}
);
export async function fetchBannedWords(
query: BannedWordQuery
): Promise<{ items: BannedWord[]; totalCount: number }> {
const all = await fetchAllBannedWords(query.keyword);
const start = (query.page - 1) * query.pageSize;
return {
items: all.slice(start, start + query.pageSize),
totalCount: all.length,
};
}
export type CreateBannedWordResult =
| { status: 'success' }
| { status: 'duplicate' };
export async function createBannedWord(
word: string
): Promise<CreateBannedWordResult> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(BEN_WORD_PATH, {
method: 'POST',
form: { benWordNm: word },
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
// 중복은 정상 흐름이라 예외로 올리지 않는다 — 백엔드가 "이미 등록된 단어 입니다"로 답한다.
if (result.message.includes('이미 등록된')) {
return { status: 'duplicate' };
}
throw new BackendRequestError(result);
}
return { status: 'success' };
}
export async function deleteBannedWord(id: number): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/${id}`, {
method: 'DELETE',
query: { benWordSn: id },
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
export async function uploadBannedWordExcel(
file: File
): Promise<ExcelUploadResult> {
const accessToken = await getSessionAccessToken();
const multipart = new FormData();
multipart.set('file', file);
const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/excel`, {
method: 'POST',
multipart,
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
});
const data = result.ok ? result.data : null;
const errors =
isRecord(data) && Array.isArray(data.errors)
? data.errors.filter((entry): entry is string => typeof entry === 'string')
: [];
if (!result.ok) {
// 검증 실패는 fail(300) + data.errors로 온다. 통신 오류와 구분해 화면에 그대로 보여준다.
if (errors.length > 0) {
return { successCount: 0, errors };
}
throw new BackendRequestError(result);
}
return {
successCount:
isRecord(data) && Number.isInteger(Number(data.successCount))
? Number(data.successCount)
: 0,
errors,
};
}