--- app/(protected)/(basic)/boards/_components/board-post-columns.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-columns.tsx
... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 |
formatAnswerStatusLabel, |
| 12 | 12 |
formatBoardCategoryLabel, |
| 13 | 13 |
formatFaqTypeLabel, |
| 14 |
+ formatContentExcerpt, |
|
| 14 | 15 |
formatInquiryTypeLabel, |
| 15 | 16 |
formatOptionalValue, |
| 16 | 17 |
formatVisibilityLabel, |
... | ... | @@ -26,6 +27,23 @@ |
| 26 | 27 |
header: '번호', |
| 27 | 28 |
width: 80, |
| 28 | 29 |
render: (_row, index) => (page - 1) * pageSize + index + 1, |
| 30 |
+ }; |
|
| 31 |
+} |
|
| 32 |
+ |
|
| 33 |
+// 이슈: 구분 값은 백엔드에 아직 없다(협의 중). 자리는 시안대로 두고 값이 생기면 배지로 돌아간다. |
|
| 34 |
+function categoryColumn(): FoxListColumn<BoardPost> {
|
|
| 35 |
+ return {
|
|
| 36 |
+ key: 'category', |
|
| 37 |
+ header: '구분', |
|
| 38 |
+ width: 120, |
|
| 39 |
+ render: (row) => |
|
| 40 |
+ row.categoryCode ? ( |
|
| 41 |
+ <FoxBadge type="pastel" color="neutral" size="md"> |
|
| 42 |
+ {formatBoardCategoryLabel(row.categoryCode)}
|
|
| 43 |
+ </FoxBadge> |
|
| 44 |
+ ) : ( |
|
| 45 |
+ EMPTY_FIELD_PLACEHOLDER |
|
| 46 |
+ ), |
|
| 29 | 47 |
}; |
| 30 | 48 |
} |
| 31 | 49 |
|
... | ... | @@ -62,16 +80,7 @@ |
| 62 | 80 |
function noticeColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] {
|
| 63 | 81 |
return [ |
| 64 | 82 |
numberColumn(page, pageSize), |
| 65 |
- {
|
|
| 66 |
- key: 'category', |
|
| 67 |
- header: '구분', |
|
| 68 |
- width: 120, |
|
| 69 |
- render: (row) => ( |
|
| 70 |
- <FoxBadge type="pastel" color="neutral" size="md"> |
|
| 71 |
- {formatBoardCategoryLabel(row.categoryCode)}
|
|
| 72 |
- </FoxBadge> |
|
| 73 |
- ), |
|
| 74 |
- }, |
|
| 83 |
+ categoryColumn(), |
|
| 75 | 84 |
{ key: 'title', header: '제목', width: 320, emphasis: true },
|
| 76 | 85 |
{
|
| 77 | 86 |
key: 'attachment', |
... | ... | @@ -116,21 +125,13 @@ |
| 116 | 125 |
|
| 117 | 126 |
return [ |
| 118 | 127 |
numberColumn(page, pageSize), |
| 119 |
- {
|
|
| 120 |
- key: 'category', |
|
| 121 |
- header: '구분', |
|
| 122 |
- width: 120, |
|
| 123 |
- render: (row) => ( |
|
| 124 |
- <FoxBadge type="pastel" color="neutral" size="md"> |
|
| 125 |
- {formatBoardCategoryLabel(row.categoryCode)}
|
|
| 126 |
- </FoxBadge> |
|
| 127 |
- ), |
|
| 128 |
- }, |
|
| 128 |
+ categoryColumn(), |
|
| 129 | 129 |
{
|
| 130 | 130 |
key: 'type', |
| 131 | 131 |
header: '유형', |
| 132 | 132 |
width: 120, |
| 133 |
- render: (row) => formatFaqTypeLabel(row.typeCode, typeOptions), |
|
| 133 |
+ // 서버가 조인해 준 이름을 그대로 쓴다. 코드표 조회는 이름이 안 온 행의 보루다. |
|
| 134 |
+ render: (row) => row.typeName ?? formatFaqTypeLabel(row.typeCode, typeOptions), |
|
| 134 | 135 |
}, |
| 135 | 136 |
{ key: 'title', header: '제목', width: 320, emphasis: true },
|
| 136 | 137 |
{
|
... | ... | @@ -189,9 +190,17 @@ |
| 189 | 190 |
key: 'type', |
| 190 | 191 |
header: '질문유형', |
| 191 | 192 |
width: 120, |
| 192 |
- render: (row) => formatInquiryTypeLabel(row.typeCode), |
|
| 193 |
+ // 서버 조인 이름 우선 — 코드표(잠정값)는 이름이 안 온 행의 보루다. |
|
| 194 |
+ render: (row) => row.typeName ?? formatInquiryTypeLabel(row.typeCode), |
|
| 193 | 195 |
}, |
| 194 |
- { key: 'title', header: '질문제목', width: 280, emphasis: true },
|
|
| 196 |
+ // 1:1문의는 제목이 없다(내용만 온다) — 내용 발췌가 이 줄의 대표 칸이다. |
|
| 197 |
+ {
|
|
| 198 |
+ key: 'content', |
|
| 199 |
+ header: '질문내용', |
|
| 200 |
+ width: 280, |
|
| 201 |
+ emphasis: true, |
|
| 202 |
+ render: (row) => formatContentExcerpt(row.content), |
|
| 203 |
+ }, |
|
| 195 | 204 |
{
|
| 196 | 205 |
key: 'authorName', |
| 197 | 206 |
header: '질문자명', |
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
... | ... | @@ -91,21 +91,12 @@ |
| 91 | 91 |
<FoxInput |
| 92 | 92 |
size="md" |
| 93 | 93 |
label="질문유형" |
| 94 |
- value={formatInquiryTypeLabel(post.typeCode)}
|
|
| 94 |
+ value={post.typeName ?? formatInquiryTypeLabel(post.typeCode)}
|
|
| 95 | 95 |
readOnly |
| 96 | 96 |
/> |
| 97 | 97 |
</div> |
| 98 | 98 |
|
| 99 | 99 |
<p className={styles.sectionTitle}>게시물의 항목 (조회 전용)</p>
|
| 100 |
- |
|
| 101 |
- <div className={styles.field}>
|
|
| 102 |
- <FoxInput |
|
| 103 |
- size="md" |
|
| 104 |
- label="질문제목" |
|
| 105 |
- value={formatOptionalValue(post.title)}
|
|
| 106 |
- readOnly |
|
| 107 |
- /> |
|
| 108 |
- </div> |
|
| 109 | 100 |
|
| 110 | 101 |
<div className={styles.field}>
|
| 111 | 102 |
<div className={styles.pair}>
|
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 |
-import type { BoardPost } from '@/lib/domain/board-post';
|
|
| 8 |
+import { formatContentExcerpt, type BoardPost } from '@/lib/domain/board-post';
|
|
| 9 | 9 |
import type { CommonCode } from '@/lib/domain/common-code';
|
| 10 | 10 |
import { deleteBoardPostAction, loadBoardPostAction } from '../../_actions';
|
| 11 | 11 |
import { InquiryAnswerModal } from './inquiry-answer-modal';
|
... | ... | @@ -61,7 +61,7 @@ |
| 61 | 61 |
showAlert({
|
| 62 | 62 |
variant: 'danger', |
| 63 | 63 |
title: '문의를 삭제하시겠습니까?', |
| 64 |
- message: `"${post.title}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
|
| 64 |
+ message: `"${formatContentExcerpt(post.content, 30)}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
|
| 65 | 65 |
actions: ( |
| 66 | 66 |
<FoxButtonGroup size="md"> |
| 67 | 67 |
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
--- app/(protected)/(basic)/contents/_components/content-form-actions.tsx
+++ app/(protected)/(basic)/contents/_components/content-form-actions.tsx
... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 |
import { useRouter } from 'next/navigation';
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 |
-import styles from './content-form.module.scss'; |
|
| 6 |
+import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
|
|
| 7 | 7 |
|
| 8 | 8 |
interface ContentFormActionsProps {
|
| 9 | 9 |
submitLabel: string; |
... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 |
pending: boolean; |
| 12 | 12 |
} |
| 13 | 13 |
|
| 14 |
-/** 폼 하단의 [목록] [등록/수정]. 시안이 가운데 정렬이다. */ |
|
| 14 |
+/** 폼 하단의 [목록] [등록/수정] — 시안 btn-page(5402:12368). 그룹이 하나라 패널이 가운데 세운다. */ |
|
| 15 | 15 |
export function ContentFormActions({
|
| 16 | 16 |
submitLabel, |
| 17 | 17 |
listHref, |
... | ... | @@ -20,22 +20,22 @@ |
| 20 | 20 |
const router = useRouter(); |
| 21 | 21 |
|
| 22 | 22 |
return ( |
| 23 |
- <div className={styles.actions}>
|
|
| 24 |
- <FoxButtonGroup size="md"> |
|
| 23 |
+ <FoxButtonPanel> |
|
| 24 |
+ <FoxButtonGroup size="lg"> |
|
| 25 | 25 |
<FoxButton |
| 26 | 26 |
type="default" |
| 27 |
- size="md" |
|
| 27 |
+ size="lg" |
|
| 28 | 28 |
label="목록" |
| 29 | 29 |
onAction={() => router.push(listHref)}
|
| 30 | 30 |
/> |
| 31 | 31 |
<FoxButton |
| 32 | 32 |
type="primary" |
| 33 |
- size="md" |
|
| 33 |
+ size="lg" |
|
| 34 | 34 |
htmlType="submit" |
| 35 | 35 |
label={submitLabel}
|
| 36 | 36 |
loading={pending}
|
| 37 | 37 |
/> |
| 38 | 38 |
</FoxButtonGroup> |
| 39 |
- </div> |
|
| 39 |
+ </FoxButtonPanel> |
|
| 40 | 40 |
); |
| 41 | 41 |
} |
--- app/(protected)/(basic)/contents/_components/content-form.module.scss
+++ app/(protected)/(basic)/contents/_components/content-form.module.scss
... | ... | @@ -113,12 +113,6 @@ |
| 113 | 113 |
padding-inline: fox.padding(6); |
| 114 | 114 |
} |
| 115 | 115 |
|
| 116 |
-.actions {
|
|
| 117 |
- display: flex; |
|
| 118 |
- justify-content: center; |
|
| 119 |
- padding-block-start: fox.padding(8); |
|
| 120 |
-} |
|
| 121 |
- |
|
| 122 | 116 |
.thumbnail {
|
| 123 | 117 |
display: flex; |
| 124 | 118 |
align-items: flex-start; |
--- app/(protected)/(basic)/contents/_components/content-video-field.tsx
+++ app/(protected)/(basic)/contents/_components/content-video-field.tsx
... | ... | @@ -5,7 +5,6 @@ |
| 5 | 5 |
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
| 6 | 6 |
import {
|
| 7 | 7 |
FILE_MODULE, |
| 8 |
- MAX_STREAM_UPLOAD_BYTES, |
|
| 9 | 8 |
allowedExtensions, |
| 10 | 9 |
fileModuleAccept, |
| 11 | 10 |
isAllowedFileExtension, |
... | ... | @@ -43,13 +42,6 @@ |
| 43 | 42 |
); |
| 44 | 43 |
return; |
| 45 | 44 |
} |
| 46 |
- if (file.size > MAX_STREAM_UPLOAD_BYTES) {
|
|
| 47 |
- setError( |
|
| 48 |
- `${Math.floor(MAX_STREAM_UPLOAD_BYTES / 1_000_000)}MB 이하만 올릴 수 있습니다.`
|
|
| 49 |
- ); |
|
| 50 |
- return; |
|
| 51 |
- } |
|
| 52 |
- |
|
| 53 | 45 |
const body = new FormData(); |
| 54 | 46 |
body.set('file', file);
|
| 55 | 47 |
|
--- app/(protected)/(basic)/contents/upload/route.ts
+++ app/(protected)/(basic)/contents/upload/route.ts
... | ... | @@ -1,9 +1,6 @@ |
| 1 | 1 |
import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
|
| 2 | 2 |
import { backendFetchUpload } from '@/lib/http/backend-fetch';
|
| 3 |
-import {
|
|
| 4 |
- MAX_STREAM_UPLOAD_BYTES, |
|
| 5 |
- findFileModule, |
|
| 6 |
-} from '@/lib/domain/file-module'; |
|
| 3 |
+import { findFileModule } from '@/lib/domain/file-module';
|
|
| 7 | 4 |
|
| 8 | 5 |
/** |
| 9 | 6 |
* 큰 파일 업로드 중계 — 브라우저 → 이 라우트 → 백엔드로 본문을 그대로 흘려보낸다. |
... | ... | @@ -11,6 +8,10 @@ |
| 11 | 8 |
* Server Action을 쓰지 않는 이유는 그 경로의 multipart가 10MB 부근에서 잘리기 때문이다 |
| 12 | 9 |
* (`MAX_ATTACHMENT_BYTES` 주석). 브라우저가 백엔드를 직접 부를 수는 없다 — 토큰이 httpOnly |
| 13 | 10 |
* 세션 안에만 있어서 여기서 붙여 준다. |
| 11 |
+ * |
|
| 12 |
+ * 크기를 여기서 막지 않는다(사용자 확정). 본문을 버퍼에 담지 않고 그대로 흘려보내므로 이 |
|
| 13 |
+ * 라우트에는 상한이 필요 없고, 실제 한도는 앞단 프록시와 백엔드가 정한다 — 넘으면 413이 |
|
| 14 |
+ * 사유와 함께 화면까지 올라간다. |
|
| 14 | 15 |
*/ |
| 15 | 16 |
|
| 16 | 17 |
const UPLOAD_PATH = '/api/v1/common/file/upload'; |
... | ... | @@ -33,14 +34,6 @@ |
| 33 | 34 |
return fail(400, '요청 형식이 올바르지 않습니다.'); |
| 34 | 35 |
} |
| 35 | 36 |
|
| 36 |
- const declaredLength = Number(request.headers.get('content-length') ?? '');
|
|
| 37 |
- if (Number.isFinite(declaredLength) && declaredLength > MAX_STREAM_UPLOAD_BYTES) {
|
|
| 38 |
- return fail( |
|
| 39 |
- 413, |
|
| 40 |
- `${Math.floor(MAX_STREAM_UPLOAD_BYTES / 1_000_000)}MB 이하만 올릴 수 있습니다.`
|
|
| 41 |
- ); |
|
| 42 |
- } |
|
| 43 |
- |
|
| 44 | 37 |
if (!request.body) {
|
| 45 | 38 |
return fail(400, '파일이 없습니다.'); |
| 46 | 39 |
} |
... | ... | @@ -57,7 +50,8 @@ |
| 57 | 50 |
); |
| 58 | 51 |
|
| 59 | 52 |
if (!result.ok) {
|
| 60 |
- return fail(502, result.message); |
|
| 53 |
+ // 프록시가 자른 413은 크기 문제라 사용자에게 그대로 보인다. 나머지는 중계 실패다. |
|
| 54 |
+ return fail(result.code === 413 ? 413 : 502, result.message); |
|
| 61 | 55 |
} |
| 62 | 56 |
|
| 63 | 57 |
if (typeof result.data !== 'string' || result.data === '') {
|
--- lib/data/repositories/board-post-repository.ts
+++ lib/data/repositories/board-post-repository.ts
... | ... | @@ -3,7 +3,6 @@ |
| 3 | 3 |
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
| 4 | 4 |
import {
|
| 5 | 5 |
BOARD_SETTING_IDS, |
| 6 |
- BOARD_TYPE_CODE_PARAM, |
|
| 7 | 6 |
formatBoardCategoryLabel, |
| 8 | 7 |
type BoardPost, |
| 9 | 8 |
type BoardType, |
... | ... | @@ -20,19 +19,17 @@ |
| 20 | 19 |
* PUT /api/v1/mngr/bbs/{bbsId}
|
| 21 | 20 |
* DELETE /api/v1/mngr/bbs/{bbsId} (논리 삭제 — DEL_YN='Y')
|
| 22 | 21 |
* |
| 23 |
- * 아래는 백엔드 저장소(edupay-backend develop 924db37)의 실제 구현을 읽고 확인한 것이다 — |
|
| 22 |
+ * 아래는 백엔드 저장소(edupay-backend origin/develop f7d601a)의 실제 구현을 읽고 확인한 것이다 — |
|
| 24 | 23 |
* MngrBbsApiController / MngrBbsServiceImpl / MngrBbsMapper.xml / PaginationUtil / CrudLogInterceptor. |
| 25 | 24 |
* |
| 26 |
- * - **쓰기 요청은 JSON이 아니라 폼 인코딩이다.** 컨트롤러의 `MngrBbsRequestVo`에 `@RequestBody`가 |
|
| 27 |
- * 없어 Spring이 요청 파라미터로 바인딩한다. 그래서 `application/x-www-form-urlencoded`로 보낸다. |
|
| 25 |
+ * - **쓰기 중 등록만 폼 인코딩이다.** insert에는 `@RequestBody`가 없고 update에는 있다. |
|
| 28 | 26 |
* - **작성자·수정자·삭제자는 보내지 않는다.** `CrudLogInterceptor`(MyBatis 플러그인)가 인증 |
| 29 | 27 |
* 주체에서 꺼내 자동 기록한다. 프론트가 보내도 덮어써진다. |
| 30 |
- * - **`totalCount`를 신뢰할 수 없다.** count 쿼리가 없어 `PaginationUtil`이 현재 페이지 행 수를 |
|
| 31 |
- * 총건수로 반환한다(학생·관리자 목록과 동일한 결함). |
|
| 32 |
- * - **노출기간·앱푸쉬는 저장되지만 조회되지 않는다.** INSERT/UPDATE에는 있으나 조회 SQL의 select |
|
| 33 |
- * 목록에 START_DT·END_DT·PUSH_YN이 없다. 사용자 확인 후 "전송은 하되 조회는 빈 값" 방침이다. |
|
| 34 |
- * - **`reg_dt` 별칭이 두 번 쓰인다**(ANS_DT에 한 번, FRST_REG_DT에 한 번). 같은 이름의 컬럼이 둘이라 |
|
| 35 |
- * 작성일 자리에 답변일이 들어올 수 있다 — 보고한 백엔드 결함이며, 값이 이상하면 이 지점을 의심한다. |
|
| 28 |
+ * - **`bbsCd`는 유형 코드다.** 조회가 코드관리 BBS_FAQ_CD와 조인해 이름(`bbsFaqNm`)까지 준다. |
|
| 29 |
+ * 반면 쓰기는 요청 VO에도 컨트롤러 복사에도 bbsCd가 없어 **유형이 저장되지 않는다**(보고함). |
|
| 30 |
+ * - **앱푸쉬는 끝까지 닿지 않는다.** 요청 VO·컨트롤러까지는 pushYn이 오지만 INSERT/UPDATE/SELECT |
|
| 31 |
+ * SQL 어디에도 PUSH_YN이 없다(보고함). 노출기간(START_DT·END_DT)은 이제 조회까지 온다. |
|
| 32 |
+ * - **구분 값이 없다.** 협의 중이라 화면 입력은 유지하되 조회·전송 모두 비워 둔다(사용자 확정). |
|
| 36 | 33 |
* |
| 37 | 34 |
* 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용. 캐시: `no-store`(조건이 매 요청 다르고 관리 데이터다). |
| 38 | 35 |
*/ |
... | ... | @@ -99,9 +96,10 @@ |
| 99 | 96 |
id, |
| 100 | 97 |
title: readString(raw, 'bbsNm') ?? '', |
| 101 | 98 |
content: readString(raw, 'bbsCn') ?? '', |
| 102 |
- categoryCode: readString(raw, 'bbsCd') ?? '', |
|
| 103 |
- // 백엔드에 유형 저장 필드가 아직 없다(BOARD_TYPE_CODE_PARAM 주석) — 응답에도 없으므로 null이다. |
|
| 104 |
- typeCode: readString(raw, BOARD_TYPE_CODE_PARAM), |
|
| 99 |
+ // 이슈: 구분 값은 백엔드에 아직 없다(협의 중). bbsCd는 구분이 아니라 유형이다. |
|
| 100 |
+ categoryCode: null, |
|
| 101 |
+ typeCode: readString(raw, 'bbsCd'), |
|
| 102 |
+ typeName: readString(raw, 'bbsFaqNm'), |
|
| 105 | 103 |
attachmentId: readString(raw, 'atchFileId'), |
| 106 | 104 |
isPinned: parseYesNo(raw.hghrkYn), |
| 107 | 105 |
isVisible: parseYesNo(raw.rlsYn), |
... | ... | @@ -274,9 +272,9 @@ |
| 274 | 272 |
pushYn: toYesNo(input.isPushEnabled), |
| 275 | 273 |
}; |
| 276 | 274 |
|
| 277 |
- // 구분(bbsCd)은 아직 요청 VO에 없어 백엔드가 무시한다. 필드가 추가되는 즉시 동작하도록 보낸다. |
|
| 278 |
- if (input.categoryCode) params.bbsCd = input.categoryCode; |
|
| 279 |
- if (input.typeCode) params[BOARD_TYPE_CODE_PARAM] = input.typeCode; |
|
| 275 |
+ // 이슈: bbsCd(유형)는 요청 VO·컨트롤러 복사에 없어 백엔드가 무시한다. 추가되는 즉시 |
|
| 276 |
+ // 동작하도록 실제 컬럼 이름으로 보낸다. 구분은 백엔드에 자리가 없어 보내지 않는다(협의 중). |
|
| 277 |
+ if (input.typeCode) params.bbsCd = input.typeCode; |
|
| 280 | 278 |
if (input.attachmentId) params.atchFileId = input.attachmentId; |
| 281 | 279 |
if (input.startDate) params.startDt = input.startDate; |
| 282 | 280 |
if (input.endDate) params.endDt = input.endDate; |
--- lib/data/repositories/content-repository.ts
+++ lib/data/repositories/content-repository.ts
... | ... | @@ -24,14 +24,14 @@ |
| 24 | 24 |
* |
| 25 | 25 |
* 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다. |
| 26 | 26 |
* |
| 27 |
- * ⚠️ 백엔드 결함(보고함, 미수정) |
|
| 28 |
- * 1. `selectPaginationCount`가 `FROM TB_COM_CNTNTS`만 두고 `searchWhere`를 그대로 포함한다. |
|
| 29 |
- * 그 조건은 `c.`·`k.`·`g.` 별칭을 쓰므로 별칭을 모르는 count 쿼리에서 SQL이 깨진다. |
|
| 30 |
- * 2. 학교·학년 조건의 `<if test='searchSchulGradeCd != null != ""'>`가 OGNL에서 항상 참이고, |
|
| 31 |
- * 본문도 값 대신 `#{searchKeyword}`를 비교한다. 파라미터를 보내도 의미가 없어 보내지 않고,
|
|
| 32 |
- * 학교·학년 필터는 받아온 뒤 여기서 건다. |
|
| 33 |
- * 3. 목록 SQL이 키워드·학교급·파일을 모두 LEFT JOIN해 한 콘텐츠가 여러 행이 된다. LIMIT이 그 |
|
| 34 |
- * 늘어난 행에 걸려 한 페이지에 오는 콘텐츠 수가 `recordCountPerPage`보다 적을 수 있다. |
|
| 27 |
+ * ⚠️ 백엔드 결함(보고함, 미수정) — 그래서 목록은 전량 수령 후 로컬 검색·정렬·페이징이다. |
|
| 28 |
+ * 1. 검색 조건(`c.CNTNTS_TITLE`·`k.CNTNTS_KEYWORD`)이 별칭을 쓰는데 `selectPaginationCount`는 |
|
| 29 |
+ * `FROM TB_COM_CNTNTS`뿐이라 검색어를 실으면 count가 SQL 오류로 죽는다. |
|
| 30 |
+ * 2. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시 뒤집어 |
|
| 31 |
+ * **옛 글부터** 나간다. 새로 등록한 글이 첫 페이지가 아니라 마지막 페이지에 붙는다. |
|
| 32 |
+ * 3. 키워드·학교급·파일 LEFT JOIN으로 한 콘텐츠가 여러 행이 되고 LIMIT이 그 행 기준이라, 페이지 |
|
| 33 |
+ * 경계에서 잘린 콘텐츠는 키워드·컷 목록이 반쪽으로 온다. |
|
| 34 |
+ * 4. 학교·학년 파라미터는 `<if>` 본문이 값 대신 `#{searchKeyword}`를 비교해 보내도 의미가 없다.
|
|
| 35 | 35 |
*/ |
| 36 | 36 |
|
| 37 | 37 |
const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns'; |
... | ... | @@ -129,17 +129,14 @@ |
| 129 | 129 |
|
| 130 | 130 |
function buildListParams( |
| 131 | 131 |
kind: ContentApiKind, |
| 132 |
- query: ContentQuery, |
|
| 133 | 132 |
pageIndex: number, |
| 134 | 133 |
recordCountPerPage: number |
| 135 | 134 |
): Record<string, string | number> {
|
| 136 |
- const keyword = query.keyword.trim(); |
|
| 137 |
- |
|
| 138 | 135 |
return {
|
| 139 | 136 |
// 없으면 `CNTNTS_TYPE = NULL`이 되어 한 건도 오지 않는다. |
| 137 |
+ // 검색어는 보내지 않는다 — 검색 조건이 c.·k. 별칭을 쓰는데 count 쿼리에는 별칭이 없어 |
|
| 138 |
+ // 키워드를 실으면 count가 SQL 오류로 죽는다(파일 상단 ⚠️ 1). 검색은 아래에서 로컬로 건다. |
|
| 140 | 139 |
searchCntntsType: CONTENT_TYPE_CODE[kind], |
| 141 |
- searchCondition: keyword ? '1' : '', |
|
| 142 |
- searchKeyword: keyword, |
|
| 143 | 140 |
pageIndex, |
| 144 | 141 |
recordCountPerPage, |
| 145 | 142 |
}; |
... | ... | @@ -147,7 +144,6 @@ |
| 147 | 144 |
|
| 148 | 145 |
async function requestList( |
| 149 | 146 |
kind: ContentApiKind, |
| 150 |
- query: ContentQuery, |
|
| 151 | 147 |
pageIndex: number, |
| 152 | 148 |
recordCountPerPage: number |
| 153 | 149 |
): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
|
... | ... | @@ -155,7 +151,7 @@ |
| 155 | 151 |
|
| 156 | 152 |
const result = await backendFetch<unknown>(`${CONTENT_BASE_PATH}/pagination`, {
|
| 157 | 153 |
method: 'GET', |
| 158 |
- query: buildListParams(kind, query, pageIndex, recordCountPerPage), |
|
| 154 |
+ query: buildListParams(kind, pageIndex, recordCountPerPage), |
|
| 159 | 155 |
accessToken: accessToken ?? undefined, |
| 160 | 156 |
cache: 'no-store', |
| 161 | 157 |
canHaveNullData: true, |
... | ... | @@ -188,26 +184,50 @@ |
| 188 | 184 |
isTotalCountExact: boolean; |
| 189 | 185 |
}; |
| 190 | 186 |
|
| 187 |
+// TODO(백엔드): 목록 정렬·LIMIT이 고쳐지면 서버 페이징으로 되돌린다. 그전까지 이 상한을 |
|
| 188 |
+// 넘는 콘텐츠는 목록에서 조용히 잘린다. |
|
| 189 |
+const FETCH_LIMIT = 500; |
|
| 190 |
+ |
|
| 191 |
+function matchesContentQuery(item: ContentItem, query: ContentQuery): boolean {
|
|
| 192 |
+ if (query.school && item.schoolCode !== query.school) {
|
|
| 193 |
+ return false; |
|
| 194 |
+ } |
|
| 195 |
+ if (query.grade && !item.grades.includes(query.grade)) {
|
|
| 196 |
+ return false; |
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 199 |
+ const keyword = query.keyword.trim().toLowerCase(); |
|
| 200 |
+ if (!keyword) {
|
|
| 201 |
+ return true; |
|
| 202 |
+ } |
|
| 203 |
+ if (query.searchField === 'keyword') {
|
|
| 204 |
+ return item.keywords.some((entry) => |
|
| 205 |
+ entry.keyword.toLowerCase().includes(keyword) |
|
| 206 |
+ ); |
|
| 207 |
+ } |
|
| 208 |
+ // title(쇼츠)·name(만화) 모두 같은 컬럼(CNTNTS_NM=CNTNTS_TITLE)이라 한 값을 본다. |
|
| 209 |
+ return item.title.toLowerCase().includes(keyword); |
|
| 210 |
+} |
|
| 211 |
+ |
|
| 191 | 212 |
export async function fetchContents( |
| 192 | 213 |
kind: ContentApiKind, |
| 193 | 214 |
query: ContentQuery |
| 194 | 215 |
): Promise<ContentPage> {
|
| 195 |
- const { items, reportedTotalCount } = await requestList(
|
|
| 196 |
- kind, |
|
| 197 |
- query, |
|
| 198 |
- query.page, |
|
| 199 |
- query.pageSize |
|
| 200 |
- ); |
|
| 216 |
+ // 서버 페이징을 쓰지 않는다 — 목록 SQL이 rnum(등록일 내림차순)을 다시 뒤집어 옛 글부터 |
|
| 217 |
+ // 내보내고, LIMIT도 조인으로 불어난 행 기준이라 새 글이 마지막 페이지로 밀린다(⚠️ 2·3). |
|
| 218 |
+ const { items } = await requestList(kind, 1, FETCH_LIMIT);
|
|
| 201 | 219 |
|
| 202 |
- const confirmedCount = (query.page - 1) * query.pageSize + items.length; |
|
| 220 |
+ const matched = items |
|
| 221 |
+ .filter((item) => matchesContentQuery(item, query)) |
|
| 222 |
+ // rnum은 resultMap에 없고 등록일은 일 단위라, 새 글이 먼저 오도록 일련번호로 세운다. |
|
| 223 |
+ .sort((a, b) => Number(b.cntntsSn) - Number(a.cntntsSn)); |
|
| 224 |
+ |
|
| 225 |
+ const offset = (query.page - 1) * query.pageSize; |
|
| 203 | 226 |
|
| 204 | 227 |
return {
|
| 205 |
- items, |
|
| 206 |
- // 백엔드 `PaginationUtil`이 count 없이 페이지 행 수를 총건수로 내려주는 결함이 있어 |
|
| 207 |
- // 하한값으로 보정한다(다른 목록 화면과 같은 처리). |
|
| 208 |
- totalCount: Math.max(reportedTotalCount, confirmedCount), |
|
| 209 |
- isTotalCountExact: |
|
| 210 |
- items.length < query.pageSize || reportedTotalCount > confirmedCount, |
|
| 228 |
+ items: matched.slice(offset, offset + query.pageSize), |
|
| 229 |
+ totalCount: matched.length, |
|
| 230 |
+ isTotalCountExact: true, |
|
| 211 | 231 |
}; |
| 212 | 232 |
} |
| 213 | 233 |
|
... | ... | @@ -299,36 +319,21 @@ |
| 299 | 319 |
|
| 300 | 320 |
|
| 301 | 321 |
/** |
| 302 |
- * 보낸 것을 서버 콘솔에 그대로 남긴다. |
|
| 303 |
- * |
|
| 304 |
- * 브라우저 네트워크 탭에는 이 요청이 **뜨지 않는다** — 화면은 Server Action을 부르고 백엔드 |
|
| 305 |
- * 호출은 서버에서 일어난다(BFF). 그래서 개발 중에는 성공·실패를 가리지 않고 찍고, 운영에서는 |
|
| 306 |
- * 실패만 남긴다. JSON은 Swagger의 Request body에 그대로 붙여 넣을 수 있는 모양이다. |
|
| 322 |
+ * 실패한 쓰기의 사유와 보낸 본문을 남긴다. 요청 자체는 테스트 환경에서 backend-fetch가 |
|
| 323 |
+ * 전부 찍으므로(중앙 로거) 여기는 실패만 맡는다 — 운영에서도 원인 추적에 이게 유일한 단서다. |
|
| 307 | 324 |
*/ |
| 308 |
-function logWrite( |
|
| 325 |
+function logWriteFailure( |
|
| 309 | 326 |
method: string, |
| 310 | 327 |
path: string, |
| 311 | 328 |
body: unknown, |
| 312 |
- failure: { code: number; message: string } | null
|
|
| 329 |
+ failure: { code: number; message: string }
|
|
| 313 | 330 |
): void {
|
| 314 |
- if (failure === null && process.env.NODE_ENV === 'production') {
|
|
| 315 |
- return; |
|
| 316 |
- } |
|
| 317 |
- |
|
| 318 |
- const head = failure |
|
| 319 |
- ? `[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`
|
|
| 320 |
- : `[contents] ${method} ${path}`;
|
|
| 321 |
- |
|
| 322 | 331 |
const lines = [ |
| 323 |
- head, |
|
| 332 |
+ `[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`,
|
|
| 324 | 333 |
body === undefined ? null : `body(JSON):\n${JSON.stringify(body, null, 2)}`,
|
| 325 | 334 |
].filter(Boolean); |
| 326 | 335 |
|
| 327 |
- if (failure) {
|
|
| 328 |
- console.error(lines.join('\n'));
|
|
| 329 |
- } else {
|
|
| 330 |
- console.info(lines.join('\n'));
|
|
| 331 |
- } |
|
| 336 |
+ console.error(lines.join('\n'));
|
|
| 332 | 337 |
} |
| 333 | 338 |
|
| 334 | 339 |
async function sendWrite( |
... | ... | @@ -347,9 +352,8 @@ |
| 347 | 352 |
canHaveEmptyBody: true, |
| 348 | 353 |
}); |
| 349 | 354 |
|
| 350 |
- logWrite(method, path, body, result.ok ? null : result); |
|
| 351 |
- |
|
| 352 | 355 |
if (!result.ok) {
|
| 356 |
+ logWriteFailure(method, path, body, result); |
|
| 353 | 357 |
throw new BackendRequestError(result); |
| 354 | 358 |
} |
| 355 | 359 |
|
--- lib/domain/board-post-query.ts
+++ lib/domain/board-post-query.ts
... | ... | @@ -39,8 +39,8 @@ |
| 39 | 39 |
{ value: 'category', label: '구분' },
|
| 40 | 40 |
{ value: 'title', label: '제목' },
|
| 41 | 41 |
], |
| 42 |
+ // 1:1문의는 제목 없이 내용만 쓴다 — 제목 검색은 대상이 없다. |
|
| 42 | 43 |
inquiry: [ |
| 43 |
- { value: 'title', label: '제목' },
|
|
| 44 | 44 |
{ value: 'authorName', label: '질문자명' },
|
| 45 | 45 |
{ value: 'authorPhoneNumber', label: '전화번호' },
|
| 46 | 46 |
{ value: 'content', label: '내용' },
|
--- lib/domain/board-post.ts
+++ lib/domain/board-post.ts
... | ... | @@ -33,24 +33,23 @@ |
| 33 | 33 |
title: string; |
| 34 | 34 |
/** 본문 — 백엔드 `bbsCn`. */ |
| 35 | 35 |
content: string; |
| 36 |
- /** 구분 코드 — 백엔드 `bbsCd`. */ |
|
| 37 |
- categoryCode: string; |
|
| 38 |
- /** |
|
| 39 |
- * 유형 코드(FAQ의 유형 / 1:1문의의 질문유형). |
|
| 40 |
- * **백엔드에 아직 저장 필드가 없다** — `BOARD_TYPE_CODE_PARAM` 주석 참조. 조회 시 항상 null이다. |
|
| 41 |
- */ |
|
| 36 |
+ /** 구분 코드. 백엔드에 해당 값이 아직 없다(협의 중) — 조회 시 항상 null이다. */ |
|
| 37 |
+ categoryCode: string | null; |
|
| 38 |
+ /** 유형 코드(FAQ의 유형 / 1:1문의의 질문유형) — 백엔드 `bbsCd`(BBS_FAQ_CD 코드). */ |
|
| 42 | 39 |
typeCode: string | null; |
| 40 |
+ /** 유형 이름 — 백엔드 `bbsFaqNm`(코드관리 BBS_FAQ_CD를 서버가 조인해 준다). */ |
|
| 41 |
+ typeName: string | null; |
|
| 43 | 42 |
/** 첨부파일 식별자 — 백엔드 `atchFileId`. 파일명·용량 조회 API가 없어 존재 여부만 알 수 있다. */ |
| 44 | 43 |
attachmentId: string | null; |
| 45 | 44 |
/** 상단고정 — 백엔드 `hghrkYn`. */ |
| 46 | 45 |
isPinned: boolean | null; |
| 47 | 46 |
/** 사용여부(노출여부) — 백엔드 `rlsYn`. */ |
| 48 | 47 |
isVisible: boolean | null; |
| 49 |
- /** 노출 시작일. 백엔드 조회 응답에 없어 현재는 항상 null이다(파일 상단 주석). */ |
|
| 48 |
+ /** 노출 시작일(YYYY-MM-DD) — 백엔드 `startDt`. */ |
|
| 50 | 49 |
startDate: string | null; |
| 51 |
- /** 노출 종료일. 위와 같다. */ |
|
| 50 |
+ /** 노출 종료일 — 백엔드 `endDt`. */ |
|
| 52 | 51 |
endDate: string | null; |
| 53 |
- /** 앱푸쉬 발송 여부. 위와 같다. */ |
|
| 52 |
+ /** 앱푸쉬 발송 여부. 조회 SQL에 PUSH_YN이 없어 아직 항상 null이다. */ |
|
| 54 | 53 |
isPushEnabled: boolean | null; |
| 55 | 54 |
/** 작성자 표기 — 백엔드 `regNm`(이름(로그인ID) 형태로 이미 조합돼 온다). */ |
| 56 | 55 |
authorName: string | null; |
... | ... | @@ -81,18 +80,6 @@ |
| 81 | 80 |
return value === null || value === '' ? EMPTY_FIELD_PLACEHOLDER : String(value); |
| 82 | 81 |
} |
| 83 | 82 |
|
| 84 |
-/** |
|
| 85 |
- * 「유형」을 실어 보낼 요청 파라미터 이름 — **임시 이름이다.** |
|
| 86 |
- * |
|
| 87 |
- * 시안은 구분과 유형을 별도 항목으로 요구하지만 백엔드에는 `BBS_CD` 하나뿐이고, 등록/수정 요청 |
|
| 88 |
- * VO(`MngrBbsRequestVo`)에는 그마저도 없다. 사용자 확인 결과 **"백엔드가 필드를 추가할 예정이니 |
|
| 89 |
- * 프론트는 미리 구현"** 이라, 구분은 `bbsCd`로 유형은 이 이름으로 보낸다. |
|
| 90 |
- * |
|
| 91 |
- * 백엔드가 실제 이름을 확정하면 이 상수 한 줄만 고치면 된다. 그전까지 백엔드는 이 파라미터를 |
|
| 92 |
- * 무시하므로 **유형은 저장되지 않는다**(화면 입력과 전송은 정상 동작한다). |
|
| 93 |
- */ |
|
| 94 |
-export const BOARD_TYPE_CODE_PARAM = 'bbsTypeCd'; |
|
| 95 |
- |
|
| 96 | 83 |
type CodeOption = { value: string; label: string };
|
| 97 | 84 |
|
| 98 | 85 |
/** |
... | ... | @@ -121,6 +108,15 @@ |
| 121 | 108 |
// TODO: 답변완료를 가리키는 코드값. BBS_ANS_CD의 실제 값이 확인되면 교체한다. |
| 122 | 109 |
export const ANSWER_STATUS_DONE = 'DONE'; |
| 123 | 110 |
|
| 111 |
+/** 목록 한 칸·확인 문구에 넣을 본문 발췌 — 줄바꿈을 접고 길면 말줄임한다. */ |
|
| 112 |
+export function formatContentExcerpt(content: string, maxLength = 60): string {
|
|
| 113 |
+ const flat = content.replace(/\s+/g, ' ').trim(); |
|
| 114 |
+ if (flat === '') {
|
|
| 115 |
+ return EMPTY_FIELD_PLACEHOLDER; |
|
| 116 |
+ } |
|
| 117 |
+ return flat.length > maxLength ? `${flat.slice(0, maxLength)}…` : flat;
|
|
| 118 |
+} |
|
| 119 |
+ |
|
| 124 | 120 |
/** 코드 → 라벨. 표에 없는 코드는 지어내지 않고 코드 그대로 노출한다(잘못된 라벨보다 낫다). */ |
| 125 | 121 |
function formatCode(options: readonly CodeOption[], code: string | null): string {
|
| 126 | 122 |
if (code === null || code === '') {
|
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
... | ... | @@ -50,7 +50,9 @@ |
| 50 | 50 |
vodContent: {
|
| 51 | 51 |
id: 'MODULE_VOD_CONTENT', |
| 52 | 52 |
description: '동영상 콘텐츠 저장소', |
| 53 |
- extensions: ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv'], |
|
| 53 |
+ // 이슈: 백엔드 전역 화이트리스트(Globals.fileUpload.Extensions)에 영상은 mp4뿐이다. |
|
| 54 |
+ // mov·avi 등은 목록에 추가되면 여기도 되돌린다. |
|
| 55 |
+ extensions: ['mp4'], |
|
| 54 | 56 |
}, |
| 55 | 57 |
/** 꾸미기 아이템 썸네일. */ |
| 56 | 58 |
userItem: {
|
... | ... | @@ -124,6 +126,3 @@ |
| 124 | 126 |
* 없다. 더 큰 파일은 미들웨어에서 제외한 스트리밍 라우트(`/contents/upload`)로 보낸다. |
| 125 | 127 |
*/ |
| 126 | 128 |
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; |
| 127 |
- |
|
| 128 |
-/** 스트리밍 업로드 상한 — 백엔드 `multipartResolver`의 `maxUploadSize`(100,000,000)와 같은 값. */ |
|
| 129 |
-export const MAX_STREAM_UPLOAD_BYTES = 100_000_000; |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -137,6 +137,49 @@ |
| 137 | 137 |
}; |
| 138 | 138 |
} |
| 139 | 139 |
|
| 140 |
+/** |
|
| 141 |
+ * 테스트 환경에서는 백엔드로 나가는 **모든** 요청을 서버 콘솔에 남긴다 — 브라우저 네트워크 |
|
| 142 |
+ * 탭에는 이 호출이 뜨지 않아(BFF) 여기가 유일하게 보이는 자리다. 운영에서는 찍지 않는다. |
|
| 143 |
+ * JSON 본문은 Swagger Request body에 그대로 붙여 넣을 수 있는 모양으로 찍는다. |
|
| 144 |
+ */ |
|
| 145 |
+function logRequest( |
|
| 146 |
+ method: string, |
|
| 147 |
+ url: string, |
|
| 148 |
+ payload?: { body?: unknown; form?: Record<string, string | number | undefined>; multipart?: FormData; note?: string }
|
|
| 149 |
+): void {
|
|
| 150 |
+ if (process.env.NODE_ENV === 'production') {
|
|
| 151 |
+ return; |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ const lines = [`[backend-fetch] ${method} ${url}`];
|
|
| 155 |
+ |
|
| 156 |
+ if (payload?.body !== undefined) {
|
|
| 157 |
+ lines.push(`body(JSON):\n${JSON.stringify(payload.body, null, 2)}`);
|
|
| 158 |
+ } |
|
| 159 |
+ if (payload?.form) {
|
|
| 160 |
+ const params = new URLSearchParams(); |
|
| 161 |
+ for (const [key, value] of Object.entries(payload.form)) {
|
|
| 162 |
+ if (value !== undefined) {
|
|
| 163 |
+ params.set(key, String(value)); |
|
| 164 |
+ } |
|
| 165 |
+ } |
|
| 166 |
+ lines.push(`form: ${params.toString()}`);
|
|
| 167 |
+ } |
|
| 168 |
+ if (payload?.multipart) {
|
|
| 169 |
+ const parts = [...payload.multipart.entries()].map(([key, value]) => |
|
| 170 |
+ value instanceof File |
|
| 171 |
+ ? `${key}=${value.name}(${Math.ceil(value.size / 1024)}KB)`
|
|
| 172 |
+ : `${key}=${String(value)}`
|
|
| 173 |
+ ); |
|
| 174 |
+ lines.push(`multipart: ${parts.join(', ')}`);
|
|
| 175 |
+ } |
|
| 176 |
+ if (payload?.note) {
|
|
| 177 |
+ lines.push(payload.note); |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ console.info(lines.join('\n'));
|
|
| 181 |
+} |
|
| 182 |
+ |
|
| 140 | 183 |
function resolveUrl( |
| 141 | 184 |
path: string, |
| 142 | 185 |
query: Record<string, string | number> | undefined |
... | ... | @@ -185,9 +228,12 @@ |
| 185 | 228 |
path: string, |
| 186 | 229 |
init: BackendStreamRequestInit |
| 187 | 230 |
): Promise<BackendResult<Response>> {
|
| 231 |
+ const url = resolveUrl(path, init.query); |
|
| 232 |
+ logRequest('GET', url, { note: '(파일 응답 스트림)' });
|
|
| 233 |
+ |
|
| 188 | 234 |
let response: Response; |
| 189 | 235 |
try {
|
| 190 |
- response = await fetch(resolveUrl(path, init.query), {
|
|
| 236 |
+ response = await fetch(url, {
|
|
| 191 | 237 |
method: 'GET', |
| 192 | 238 |
headers: {
|
| 193 | 239 |
...(init.accessToken |
... | ... | @@ -262,10 +308,17 @@ |
| 262 | 308 |
init: BackendRequestInit |
| 263 | 309 |
): Promise<BackendResult<T>> {
|
| 264 | 310 |
const { body, contentType } = buildRequestBody(init);
|
| 311 |
+ const url = resolveUrl(path, init.query); |
|
| 312 |
+ |
|
| 313 |
+ logRequest(init.method, url, {
|
|
| 314 |
+ body: init.body, |
|
| 315 |
+ form: init.form, |
|
| 316 |
+ multipart: init.multipart, |
|
| 317 |
+ }); |
|
| 265 | 318 |
|
| 266 | 319 |
let response: Response; |
| 267 | 320 |
try {
|
| 268 |
- response = await fetch(resolveUrl(path, init.query), {
|
|
| 321 |
+ response = await fetch(url, {
|
|
| 269 | 322 |
method: init.method, |
| 270 | 323 |
headers: {
|
| 271 | 324 |
...(contentType ? { 'Content-Type': contentType } : {}),
|
... | ... | @@ -298,6 +351,15 @@ |
| 298 | 351 |
}; |
| 299 | 352 |
} |
| 300 | 353 |
|
| 354 |
+ // 이슈: dev API 프록시(client_max_body_size 미설정, 기본 1MiB)가 앱보다 먼저 큰 본문을 |
|
| 355 |
+ // 봉투 없이 413으로 거절한다(2026-08-20 실측). 인프라 상향 전까지 사유를 살려 내보낸다. |
|
| 356 |
+ if (response.status === 413) {
|
|
| 357 |
+ return {
|
|
| 358 |
+ ok: false, |
|
| 359 |
+ code: 413, |
|
| 360 |
+ message: '파일이 서버가 받을 수 있는 크기를 넘었습니다.', |
|
| 361 |
+ }; |
|
| 362 |
+ } |
|
| 301 | 363 |
return communicationError(`예상치 못한 HTTP 상태: ${response.status}`);
|
| 302 | 364 |
} |
| 303 | 365 |
|
... | ... | @@ -359,9 +421,12 @@ |
| 359 | 421 |
timeoutMs?: number; |
| 360 | 422 |
} |
| 361 | 423 |
): Promise<BackendResult<T>> {
|
| 424 |
+ const url = resolveUrl(path, undefined); |
|
| 425 |
+ logRequest('POST', url, { note: '(multipart 스트림 본문 — 내용 생략)' });
|
|
| 426 |
+ |
|
| 362 | 427 |
let response: Response; |
| 363 | 428 |
try {
|
| 364 |
- response = await fetch(resolveUrl(path, undefined), {
|
|
| 429 |
+ response = await fetch(url, {
|
|
| 365 | 430 |
method: 'POST', |
| 366 | 431 |
headers: {
|
| 367 | 432 |
'Content-Type': init.contentType, |
... | ... | @@ -392,6 +457,13 @@ |
| 392 | 457 |
: COMMUNICATION_ERROR_MESSAGE, |
| 393 | 458 |
}; |
| 394 | 459 |
} |
| 460 |
+ if (response.status === 413) {
|
|
| 461 |
+ return {
|
|
| 462 |
+ ok: false, |
|
| 463 |
+ code: 413, |
|
| 464 |
+ message: '파일이 서버가 받을 수 있는 크기를 넘었습니다.', |
|
| 465 |
+ }; |
|
| 466 |
+ } |
|
| 395 | 467 |
return communicationError( |
| 396 | 468 |
`업로드 응답의 예상치 못한 HTTP 상태: ${response.status}`
|
| 397 | 469 |
); |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?