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 {
BOARD_SETTING_IDS,
BOARD_TYPE_CODE_PARAM,
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 develop 924db37)의 실제 구현을 읽고 확인한 것이다 —
* MngrBbsApiController / MngrBbsServiceImpl / MngrBbsMapper.xml / PaginationUtil / CrudLogInterceptor.
*
* - **쓰기 요청은 JSON이 아니라 폼 인코딩이다.** 컨트롤러의 `MngrBbsRequestVo`에 `@RequestBody`가
* 없어 Spring이 요청 파라미터로 바인딩한다. 그래서 `application/x-www-form-urlencoded`로 보낸다.
* - **작성자·수정자·삭제자는 보내지 않는다.** `CrudLogInterceptor`(MyBatis 플러그인)가 인증
* 주체에서 꺼내 자동 기록한다. 프론트가 보내도 덮어써진다.
* - **`totalCount`를 신뢰할 수 없다.** count 쿼리가 없어 `PaginationUtil`이 현재 페이지 행 수를
* 총건수로 반환한다(학생·관리자 목록과 동일한 결함).
* - **노출기간·앱푸쉬는 저장되지만 조회되지 않는다.** INSERT/UPDATE에는 있으나 조회 SQL의 select
* 목록에 START_DT·END_DT·PUSH_YN이 없다. 사용자 확인 후 "전송은 하되 조회는 빈 값" 방침이다.
* - **`reg_dt` 별칭이 두 번 쓰인다**(ANS_DT에 한 번, FRST_REG_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';
/**
* 한 번에 받아올 최대 행 수. **백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다** — 사용자가
* "전체 조회 후 프론트에서 필터링"을 선택했기 때문이다.
*
* 그렇게 정한 이유: ① 백엔드 목록 쿼리의 검색 조건이 제목 하나뿐이고 `searchUseYn`은 쿼리에서
* 아예 쓰이지 않아 시안의 필터를 백엔드에 맡길 수 없다. ② `totalCount`가 전체 건수가 아니라
* 페이지가 가득 차면 다음 페이지에 닿을 수 없다. 전체를 손에 쥐면 두 문제가 함께 풀린다.
*
* 게시물이 이 상한을 넘으면 그 위로는 조회·검색 대상에서 빠진다 — 그 규모가 되면 백엔드에 count와
* 검색 조건이 필요하다(상한 인상은 임시방편일 뿐이다).
*/
const BOARD_FETCH_LIMIT = 10_000;
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') ?? '',
categoryCode: readString(raw, 'bbsCd') ?? '',
// 백엔드에 유형 저장 필드가 아직 없다(BOARD_TYPE_CODE_PARAM 주석) — 응답에도 없으므로 null이다.
typeCode: readString(raw, BOARD_TYPE_CODE_PARAM),
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 fetchAllBoardPosts(boardType: BoardType): Promise<BoardPost[]> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(BOARD_LIST_PATH, {
method: 'GET',
query: {
searchStngId: BOARD_SETTING_IDS[boardType],
pageIndex: 1,
recordCountPerPage: BOARD_FETCH_LIMIT,
},
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.map(toBoardPost);
}
/** 검색 대상 → 비교할 값. 목록·상세에 실제로 보이는 값으로만 거른다. */
const SEARCH_VALUE_BY_FIELD: Record<string, (post: BoardPost) => string> = {
title: (post) => post.title,
content: (post) => post.content,
authorName: (post) => post.authorName ?? '',
authorPhoneNumber: (post) => post.authorPhoneNumber ?? '',
answerContent: (post) => post.answerContent ?? '',
};
function matchesQuery(post: BoardPost, query: BoardPostQuery): boolean {
if (query.category && post.categoryCode !== query.category) {
return false;
}
if (query.type && post.typeCode !== query.type) {
return false;
}
if (query.visibility === 'visible' && post.isVisible !== true) {
return false;
}
if (query.visibility === 'hidden' && post.isVisible === true) {
return false;
}
// 답변여부는 별도 플래그가 없어 답변 내용의 유무로 판단한다(진행상태 코드는 잠정값이라
// 그것으로 판정하면 코드가 확정될 때 함께 틀어진다).
const hasAnswer = Boolean(post.answerContent);
if (query.answered === 'answered' && !hasAnswer) {
return false;
}
if (query.answered === 'unanswered' && hasAnswer) {
return false;
}
const keyword = query.keyword.trim().toLowerCase();
if (!keyword) {
return true;
}
const readValue = SEARCH_VALUE_BY_FIELD[query.searchField];
return readValue ? readValue(post).toLowerCase().includes(keyword) : true;
}
export type BoardPostPage = {
items: BoardPost[];
/** 검색·필터를 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */
totalCount: number;
};
/**
* 검색·필터·페이징이 적용된 목록을 조회한다.
*
* 정렬은 하지 않는다 — 백엔드 목록 SQL이 `ORDER BY rnum DESC`(= 최초등록일시 최신순)로 고정돼
* 있고 그것이 곧 시안의 "최근등록순"이다. 시안의 정렬 select에도 다른 선택지가 없다.
*/
export async function fetchBoardPosts(
boardType: BoardType,
query: BoardPostQuery
): Promise<BoardPostPage> {
const all = await fetchAllBoardPosts(boardType);
const matched = all.filter((post) => matchesQuery(post, query));
const offset = (query.page - 1) * query.pageSize;
return {
items: matched.slice(offset, offset + query.pageSize),
totalCount: matched.length,
};
}
/** 단건 조회 — 수정·답변 팝업의 진입점. */
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.categoryCode) params.bbsCd = input.categoryCode;
if (input.typeCode) params[BOARD_TYPE_CODE_PARAM] = 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',
form: 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);
}
}