File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import {
BOARD_SETTING_IDS,
type BoardPost,
type BoardType,
} from '@/lib/domain/board-post';
import type { BoardPostQuery } from '@/lib/domain/board-post-query';
/**
* 게시판 Repository — 공지사항·1:1문의·FAQ가 **같은 백엔드 API**를 쓰므로 한 파일이 셋을 모두
* 담당하고, 게시판 구분은 `BoardType` → `stngId` 변환으로만 처리한다.
*
* GET /api/v1/mngr/bbs/pagination (searchStngId 필수)
* GET /api/v1/mngr/bbs/{bbsId}
* POST /api/v1/mngr/bbs
* PUT /api/v1/mngr/bbs/{bbsId}
* DELETE /api/v1/mngr/bbs/{bbsId} (논리 삭제 — DEL_YN='Y')
*
* 아래는 백엔드 저장소(edupay-backend origin/develop 821f655)의 실제 구현을 읽고 확인한 것이다 —
* MngrBbsApiController / MngrBbsServiceImpl / MngrBbsMapper.xml / PaginationUtil / CrudLogInterceptor.
*
* - **쓰기는 모두 JSON이다.** 종전에는 등록만 `@RequestBody`가 없어 폼이었는데 821f655에서 붙었다.
* - **작성자·수정자·삭제자는 보내지 않는다.** `CrudLogInterceptor`(MyBatis 플러그인)가 인증
* 주체에서 꺼내 자동 기록한다. 프론트가 보내도 덮어써진다.
* - **`bbsCd`는 유형 코드다.** 조회가 코드관리 BBS_FAQ_CD와 조인해 이름(`bbsFaqNm`)까지 준다.
* 반면 쓰기는 요청 VO에도 컨트롤러 복사에도 bbsCd가 없어 **유형이 저장되지 않는다**(보고함).
* INSERT/UPDATE SQL에는 자리가 있어, 보내는 값은 그대로 두면 VO에 필드가 생기는 즉시 동작한다.
* - **앱푸쉬는 끝까지 닿지 않는다.** 요청 VO·컨트롤러까지는 pushYn이 오지만 INSERT/UPDATE/SELECT
* SQL 어디에도 PUSH_YN이 없다(보고함). 노출기간(START_DT·END_DT)은 이제 조회까지 온다.
* - **구분 값이 없다.** 협의 중이라 화면 입력은 유지하되 조회·전송 모두 비워 둔다(사용자 확정).
*
* 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용. 캐시: `no-store`(조건이 매 요청 다르고 관리 데이터다).
*/
const BOARD_LIST_PATH = '/api/v1/mngr/bbs/pagination';
const BOARD_BASE_PATH = '/api/v1/mngr/bbs';
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;
}
/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */
function parseYesNo(value: unknown): boolean | null {
if (value === 'Y') return true;
if (value === 'N') return false;
return null;
}
/** `inqCnt`는 VO 타입이 String이라 숫자 문자열로 온다 — 숫자로 못 읽으면 null. */
function parseCount(value: unknown): number | null {
if (typeof value === 'number') return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
/**
* 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다.
*
* `bbsId`만 없으면 예외로 끊는다(fail-fast) — 행의 key이자 수정·삭제의 입력값이라 없으면 목록
* 자체가 성립하지 않는다. 제목·내용은 비어 있어도 화면이 `-`로 표시하면 되므로 끊지 않는다.
*/
function toBoardPost(raw: unknown): BoardPost {
if (!isRecord(raw)) {
throw new Error('게시판 응답 항목의 형식이 올바르지 않습니다.');
}
const id = readString(raw, 'bbsId');
if (id === null) {
throw new Error('게시판 응답에 bbsId가 없습니다.');
}
return {
id,
title: readString(raw, 'bbsNm') ?? '',
content: readString(raw, 'bbsCn') ?? '',
// 이슈: 구분 값은 백엔드에 아직 없다(협의 중). bbsCd는 구분이 아니라 유형이다.
categoryCode: null,
typeCode: readString(raw, 'bbsCd'),
typeName: readString(raw, 'bbsFaqNm'),
attachmentId: readString(raw, 'atchFileId'),
isPinned: parseYesNo(raw.hghrkYn),
isVisible: parseYesNo(raw.rlsYn),
startDate: readString(raw, 'startDt'),
endDate: readString(raw, 'endDt'),
isPushEnabled: parseYesNo(raw.pushYn),
authorName: readString(raw, 'regNm'),
createdAt: readString(raw, 'regDt'),
viewCount: parseCount(raw.inqCnt),
authorPhoneNumber: readString(raw, 'telNo'),
authorEmail: readString(raw, 'emlAddr'),
answerContent: readString(raw, 'ansCn'),
answeredAt: readString(raw, 'ansDt'),
answererName: readString(raw, 'answrNm'),
answerStatusCode: readString(raw, 'ansSttsCd'),
};
}
/** 게시판 전체를 한 번에 받아온다(검색·필터·페이징은 호출부가 처리한다). */
async function requestBoardPage(
boardType: BoardType,
params: Record<string, string | number>
): Promise<{ items: BoardPost[]; totalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(BOARD_LIST_PATH, {
method: 'GET',
query: { searchStngId: BOARD_SETTING_IDS[boardType], ...params },
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('게시판 목록 응답의 형식이 올바르지 않습니다.');
}
const totalCount =
typeof data.totalCount === 'number' ? data.totalCount : data.list.length;
return { items: data.list.map(toBoardPost), totalCount };
}
export type BoardPostPage = {
items: BoardPost[];
/** 검색·필터를 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */
totalCount: number;
};
// 질문내용·질문자명 검색은 백엔드에 조건이 없어 전 페이지를 받아 거른다. 상한을 넘으면 그 뒤는 잘린다.
// TODO(백엔드): MngrBbsMapper에 searchCondition 2(BBS_CN)·3(작성자)이 생기면 서버 검색으로 바꾼다.
const SCAN_PAGE_SIZE = 500;
const MAX_SCAN_PAGES = 20;
async function fetchAllBoardPosts(
boardType: BoardType,
params: Record<string, string | number>
): Promise<BoardPost[]> {
const items: BoardPost[] = [];
for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) {
const page = await requestBoardPage(boardType, {
...params,
pageIndex,
recordCountPerPage: SCAN_PAGE_SIZE,
});
items.push(...page.items);
if (page.items.length < SCAN_PAGE_SIZE || items.length >= page.totalCount) break;
}
return items;
}
function matchesLocalSearch(post: BoardPost, field: string, keyword: string): boolean {
const needle = keyword.toLowerCase();
if (field === 'content') return post.content.toLowerCase().includes(needle);
if (field === 'author') return (post.authorName ?? '').toLowerCase().includes(needle);
return true;
}
/**
* 검색·필터·페이징이 적용된 목록을 조회한다.
*
* 정렬은 하지 않는다 — 백엔드 목록 SQL이 `ORDER BY rnum DESC`(= 최초등록일시 최신순)로 고정돼
* 있고 그것이 곧 시안의 "최근등록순"이다. 시안의 정렬 select에도 다른 선택지가 없다.
*/
export async function fetchBoardPosts(
boardType: BoardType,
query: BoardPostQuery
): Promise<BoardPostPage> {
const params: Record<string, string | number> = {};
// 백엔드가 거를 수 있는 둘만 넘긴다 — 제목(searchCondition=1)과 노출여부(RLS_YN).
// 나머지 조건은 화면에서 감췄다(`BOARD_SEARCH_FIELDS`·`BOARD_QUERY_FIELDS`).
const keyword = query.keyword.trim();
if (keyword && query.searchField === 'title') {
params.searchCondition = '1';
params.searchKeyword = keyword;
}
if (query.visibility === 'visible') {
params.searchUseYn = 'Y';
} else if (query.visibility === 'hidden') {
params.searchUseYn = 'N';
}
// 백엔드가 못 거르는 대상(질문내용·질문자명)은 전부 받아 여기서 거르고 페이지로 자른다.
const isLocalSearch = keyword !== '' && (query.searchField === 'content' || query.searchField === 'author');
if (isLocalSearch) {
const matched = (await fetchAllBoardPosts(boardType, params)).filter((post) =>
matchesLocalSearch(post, query.searchField, keyword)
);
const start = (query.page - 1) * query.pageSize;
return { items: matched.slice(start, start + query.pageSize), totalCount: matched.length };
}
return requestBoardPage(boardType, {
...params,
pageIndex: query.page,
recordCountPerPage: query.pageSize,
});
}
/** 단건 조회 — 수정·답변 팝업의 진입점. */
export async function findBoardPostById(
bbsId: string
): Promise<BoardPost | null> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${BOARD_BASE_PATH}/${bbsId}`, {
method: 'GET',
accessToken: accessToken ?? undefined,
cache: 'no-store',
});
if (!result.ok) {
throw new BackendRequestError(result);
}
return result.data === null ? null : toBoardPost(result.data);
}
/** boolean → 백엔드 Y/N 플래그. */
function toYesNo(value: boolean): string {
return value ? 'Y' : 'N';
}
export type BoardPostWriteInput = {
categoryCode: string;
typeCode: string;
title: string;
content: string;
attachmentId: string;
isPinned: boolean;
isVisible: boolean;
isPushEnabled: boolean;
startDate: string;
endDate: string;
};
/**
* 등록/수정 공통 파라미터.
*
* 빈 문자열은 보내지 않는다 — 수정 SQL이 `<if test="... != null">`로 동적 SET을 만들기 때문에,
* 빈 문자열을 보내면 기존 값을 빈 값으로 덮어쓴다(예: 첨부파일을 안 건드렸는데 지워지는 상황).
* "값을 안 보냄 = 기존 유지"가 백엔드의 규약이라 그에 맞춘다.
*/
function buildWriteParams(
boardType: BoardType,
input: BoardPostWriteInput
): Record<string, string> {
const params: Record<string, string> = {
stngId: BOARD_SETTING_IDS[boardType],
bbsNm: input.title,
bbsCn: input.content,
hghrkYn: toYesNo(input.isPinned),
rlsYn: toYesNo(input.isVisible),
pushYn: toYesNo(input.isPushEnabled),
};
// 이슈: bbsCd(유형)는 요청 VO·컨트롤러 복사에 없어 백엔드가 무시한다. 추가되는 즉시
// 동작하도록 실제 컬럼 이름으로 보낸다. 구분은 백엔드에 자리가 없어 보내지 않는다(협의 중).
if (input.typeCode) params.bbsCd = input.typeCode;
if (input.attachmentId) params.atchFileId = input.attachmentId;
if (input.startDate) params.startDt = input.startDate;
if (input.endDate) params.endDt = input.endDate;
return params;
}
export async function createBoardPost(
boardType: BoardType,
input: BoardPostWriteInput
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<null>(BOARD_BASE_PATH, {
method: 'POST',
body: buildWriteParams(boardType, input),
accessToken: accessToken ?? undefined,
cache: 'no-store',
// 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)).
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
export async function updateBoardPost(
boardType: BoardType,
bbsId: string,
input: BoardPostWriteInput
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<null>(`${BOARD_BASE_PATH}/${bbsId}`, {
method: 'PUT',
body: buildWriteParams(boardType, input),
accessToken: accessToken ?? undefined,
cache: 'no-store',
// 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)).
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
export type InquiryAnswerInput = {
answerStatusCode: string;
answerContent: string;
attachmentId: string;
};
/**
* 1:1문의 답변 저장 — 백엔드에 전용 엔드포인트가 없어 수정 API에 답변 필드를 실어 보낸다.
*
* 제목·내용은 보내지 않는다(질문자가 쓴 값이라 관리자가 건드릴 이유가 없고, 위에서 적었듯
* 안 보내면 기존 값이 유지된다). `stngId`는 수정 SQL의 WHERE 조건이라 반드시 필요하다.
*/
export async function saveInquiryAnswer(
bbsId: string,
input: InquiryAnswerInput
): Promise<void> {
const accessToken = await getSessionAccessToken();
const payload: Record<string, string> = {
stngId: BOARD_SETTING_IDS.inquiry,
ansSttsCd: input.answerStatusCode,
};
if (input.answerContent) payload.ansCn = input.answerContent;
if (input.attachmentId) payload.atchFileId = input.attachmentId;
const result = await backendFetch<null>(`${BOARD_BASE_PATH}/${bbsId}`, {
method: 'PUT',
body: payload,
accessToken: accessToken ?? undefined,
cache: 'no-store',
// 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)).
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
/** 삭제 — 백엔드가 물리 삭제가 아니라 `DEL_YN='Y'`로 표시만 바꾼다. */
export async function deleteBoardPost(bbsId: string): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<null>(`${BOARD_BASE_PATH}/${bbsId}`, {
method: 'DELETE',
accessToken: accessToken ?? undefined,
cache: 'no-store',
// 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)).
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}