revert: 콘텐츠·퀴즈 등록을 다시 form으로 되돌린다
JSON으로 바꿨지만 배포된 백엔드가 아직 받지 못한다. 실제 문서로 확인했다 — `/v3/api-docs`에서 두 POST 모두 `requestBody`가 없고 `requestVo`가 `in: "query"`인 필수 파라미터로 선언돼 있다. 그래서 JSON 본문을 보내면 파라미터 누락으로 400이 나고, 그 400은 `DefaultHandlerExceptionResolver`가 `sendError`로 처리해 앱의 JSON 봉투가 아니라 Tomcat 기본 HTML로 온다(사용자 확인). 수정(PUT)은 `@RequestBody`가 있어 JSON 그대로 두고, 등록(POST)만 form으로 되돌린다. `toFormParams`를 두 Repository에 복구하고 실패 로그도 보낸 form을 다시 남긴다 — 등록이 실패하면 어떤 값을 보냈는지가 유일한 단서다. hub가 좁혀 둔 "실패만 기록"은 그대로 유지한다. JSON 전환은 폐기가 아니라 보류다. 백엔드가 `insert`에 `@RequestBody`를 붙이면 `toFormParams`를 지우고 되돌리면 되며, 반영 여부는 `/v3/api-docs`의 `requestBody` 유무로 확인한다 — 그 방법을 두 파일의 TODO에 적어 두었다. 확인(임시 프로브로 실제 요청 캡처): 등록 2건 모두 `application/x-www-form-urlencoded` + `schulGrdList[0].schulCd` 인덱스 표기, 수정은 `application/json` 유지. Co-Authored-By: Claude Opus 5
@20a26e7cd2cf2c6fa3ab4b994b1411f9083e17e3
--- lib/data/repositories/content-quiz-repository.ts
+++ lib/data/repositories/content-quiz-repository.ts
... | ... | @@ -7,11 +7,11 @@ |
| 7 | 7 |
|
| 8 | 8 |
/** |
| 9 | 9 |
* 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)은 다르지만 콘텐츠관리 API 아래로 |
| 10 |
- * 들어와 규약은 같다(등록·수정 모두 JSON, 삭제는 soft delete). |
|
| 10 |
+ * 들어와 규약은 같다(등록은 form, 수정은 JSON, 삭제는 soft delete). |
|
| 11 | 11 |
* |
| 12 | 12 |
* ``` |
| 13 | 13 |
* GET /api/v1/mngr/cntntns/quiz/pagination 학교·학년·난이도·검색어 |
| 14 |
- * POST /api/v1/mngr/cntntns/quiz @RequestBody(JSON) |
|
| 14 |
+ * POST /api/v1/mngr/cntntns/quiz 어노테이션 없음 → form(중첩 목록은 인덱스 표기) |
|
| 15 | 15 |
* PUT /api/v1/mngr/cntntns/quiz/{quizSn} @RequestBody(JSON)
|
| 16 | 16 |
* DELETE /api/v1/mngr/cntntns/quiz/{quizSn} soft delete(DEL_YN)
|
| 17 | 17 |
* GET /api/v1/mngr/cntntns/quiz/excel/download 엑셀 다운로드 |
... | ... | @@ -237,16 +237,45 @@ |
| 237 | 237 |
} |
| 238 | 238 |
|
| 239 | 239 |
|
| 240 |
+/** |
|
| 241 |
+ * 등록은 `@RequestBody`가 없어 form으로 간다(쇼츠·만화와 같은 갈림). |
|
| 242 |
+ * |
|
| 243 |
+ * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로 |
|
| 244 |
+ * 통일한다. 반영 여부는 `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 본다. |
|
| 245 |
+ */ |
|
| 246 |
+function toFormParams( |
|
| 247 |
+ payload: QuizWritePayload |
|
| 248 |
+): Record<string, string | number | undefined> {
|
|
| 249 |
+ const params: Record<string, string | number | undefined> = {
|
|
| 250 |
+ quizTitle: payload.quizTitle, |
|
| 251 |
+ quizLev: payload.quizLev, |
|
| 252 |
+ quizQuestion: payload.quizQuestion, |
|
| 253 |
+ quizExplan: payload.quizExplan, |
|
| 254 |
+ quizAnswer: payload.quizAnswer, |
|
| 255 |
+ useYn: payload.useYn, |
|
| 256 |
+ }; |
|
| 257 |
+ |
|
| 258 |
+ payload.schulGrdList.forEach((item, index) => {
|
|
| 259 |
+ params[`schulGrdList[${index}].schulCd`] = item.schulCd;
|
|
| 260 |
+ // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다. |
|
| 261 |
+ params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined;
|
|
| 262 |
+ }); |
|
| 263 |
+ |
|
| 264 |
+ return params; |
|
| 265 |
+} |
|
| 266 |
+ |
|
| 240 | 267 |
async function sendWrite( |
| 241 | 268 |
path: string, |
| 242 | 269 |
method: 'POST' | 'PUT' | 'DELETE', |
| 243 |
- body?: unknown |
|
| 270 |
+ body?: unknown, |
|
| 271 |
+ form?: Record<string, string | number | undefined> |
|
| 244 | 272 |
): Promise<void> {
|
| 245 | 273 |
const accessToken = await getSessionAccessToken(); |
| 246 | 274 |
|
| 247 | 275 |
const result = await backendFetch<unknown>(path, {
|
| 248 | 276 |
method, |
| 249 | 277 |
...(body === undefined ? {} : { body }),
|
| 278 |
+ ...(form === undefined ? {} : { form }),
|
|
| 250 | 279 |
accessToken: accessToken ?? undefined, |
| 251 | 280 |
cache: 'no-store', |
| 252 | 281 |
canHaveNullData: true, |
... | ... | @@ -259,7 +288,7 @@ |
| 259 | 288 |
} |
| 260 | 289 |
|
| 261 | 290 |
export async function createQuiz(values: QuizFormValues): Promise<void> {
|
| 262 |
- await sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values)); |
|
| 291 |
+ await sendWrite(QUIZ_PATH, 'POST', undefined, toFormParams(buildQuizPayload(values))); |
|
| 263 | 292 |
} |
| 264 | 293 |
|
| 265 | 294 |
export async function updateQuiz( |
--- lib/data/repositories/content-repository.ts
+++ lib/data/repositories/content-repository.ts
... | ... | @@ -17,7 +17,7 @@ |
| 17 | 17 |
* ``` |
| 18 | 18 |
* GET /api/v1/mngr/cntntns/pagination searchCntntsType 필수 |
| 19 | 19 |
* GET /api/v1/mngr/cntntns/{cntntsSn} 단건
|
| 20 |
- * POST /api/v1/mngr/cntntns @RequestBody(JSON) |
|
| 20 |
+ * POST /api/v1/mngr/cntntns 어노테이션 없음 → form(중첩 목록은 인덱스 표기) |
|
| 21 | 21 |
* PUT /api/v1/mngr/cntntns/{cntntsSn} @RequestBody(JSON)
|
| 22 | 22 |
* DELETE /api/v1/mngr/cntntns/{cntntsSn} soft delete(DEL_YN)
|
| 23 | 23 |
* ``` |
... | ... | @@ -319,6 +319,40 @@ |
| 319 | 319 |
|
| 320 | 320 |
|
| 321 | 321 |
/** |
| 322 |
+ * 등록은 `@RequestBody`가 없어 form으로 간다 — 중첩 목록은 Spring이 읽는 인덱스 표기 |
|
| 323 |
+ * (`keywordList[0].cntntsKeyword`)로 편다. JSON으로 보내면 한 칸도 바인딩되지 않는다. |
|
| 324 |
+ * |
|
| 325 |
+ * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로 |
|
| 326 |
+ * 통일한다(사용자가 그 방향으로 확정했고, 백엔드 반영만 남았다). 반영 여부는 배포 서버의 |
|
| 327 |
+ * `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 확인한다 — 지금은 |
|
| 328 |
+ * `parameters: [{ name: "requestVo", in: "query" }]`라 JSON 본문을 보내면 400이 온다.
|
|
| 329 |
+ */ |
|
| 330 |
+function toFormParams( |
|
| 331 |
+ payload: ContentWritePayload & { cntntsType?: string }
|
|
| 332 |
+): Record<string, string | number | undefined> {
|
|
| 333 |
+ const params: Record<string, string | number | undefined> = {
|
|
| 334 |
+ cntntsType: payload.cntntsType, |
|
| 335 |
+ cntntsNm: payload.cntntsNm, |
|
| 336 |
+ cntntsTitle: payload.cntntsTitle, |
|
| 337 |
+ atchFileId: payload.atchFileId, |
|
| 338 |
+ thumbAtchFileId: payload.thumbAtchFileId, |
|
| 339 |
+ useYn: payload.useYn, |
|
| 340 |
+ }; |
|
| 341 |
+ |
|
| 342 |
+ payload.keywordList.forEach((item, index) => {
|
|
| 343 |
+ params[`keywordList[${index}].cntntsKeyword`] = item.cntntsKeyword;
|
|
| 344 |
+ params[`keywordList[${index}].cntntsExpln`] = item.cntntsExpln;
|
|
| 345 |
+ }); |
|
| 346 |
+ payload.schulGrdList.forEach((item, index) => {
|
|
| 347 |
+ params[`schulGrdList[${index}].schulCd`] = item.schulCd;
|
|
| 348 |
+ // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다. |
|
| 349 |
+ params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined;
|
|
| 350 |
+ }); |
|
| 351 |
+ |
|
| 352 |
+ return params; |
|
| 353 |
+} |
|
| 354 |
+ |
|
| 355 |
+/** |
|
| 322 | 356 |
* 실패한 쓰기의 사유와 보낸 본문을 남긴다. 요청 자체는 테스트 환경에서 backend-fetch가 |
| 323 | 357 |
* 전부 찍으므로(중앙 로거) 여기는 실패만 맡는다 — 운영에서도 원인 추적에 이게 유일한 단서다. |
| 324 | 358 |
*/ |
... | ... | @@ -326,11 +360,19 @@ |
| 326 | 360 |
method: string, |
| 327 | 361 |
path: string, |
| 328 | 362 |
body: unknown, |
| 363 |
+ form: Record<string, string | number | undefined> | undefined, |
|
| 329 | 364 |
failure: { code: number; message: string }
|
| 330 | 365 |
): void {
|
| 331 | 366 |
const lines = [ |
| 332 | 367 |
`[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`,
|
| 333 | 368 |
body === undefined ? null : `body(JSON):\n${JSON.stringify(body, null, 2)}`,
|
| 369 |
+ form === undefined |
|
| 370 |
+ ? null |
|
| 371 |
+ : `form:\n${new URLSearchParams(
|
|
| 372 |
+ Object.entries(form).flatMap(([key, value]) => |
|
| 373 |
+ value === undefined ? [] : [[key, String(value)] as [string, string]] |
|
| 374 |
+ ) |
|
| 375 |
+ ).toString()}`, |
|
| 334 | 376 |
].filter(Boolean); |
| 335 | 377 |
|
| 336 | 378 |
console.error(lines.join('\n'));
|
... | ... | @@ -339,13 +381,15 @@ |
| 339 | 381 |
async function sendWrite( |
| 340 | 382 |
path: string, |
| 341 | 383 |
method: 'POST' | 'PUT' | 'DELETE', |
| 342 |
- body?: unknown |
|
| 384 |
+ body?: unknown, |
|
| 385 |
+ form?: Record<string, string | number | undefined> |
|
| 343 | 386 |
): Promise<string | null> {
|
| 344 | 387 |
const accessToken = await getSessionAccessToken(); |
| 345 | 388 |
|
| 346 | 389 |
const result = await backendFetch<unknown>(path, {
|
| 347 | 390 |
method, |
| 348 | 391 |
...(body === undefined ? {} : { body }),
|
| 392 |
+ ...(form === undefined ? {} : { form }),
|
|
| 349 | 393 |
accessToken: accessToken ?? undefined, |
| 350 | 394 |
cache: 'no-store', |
| 351 | 395 |
canHaveNullData: true, |
... | ... | @@ -353,7 +397,7 @@ |
| 353 | 397 |
}); |
| 354 | 398 |
|
| 355 | 399 |
if (!result.ok) {
|
| 356 |
- logWriteFailure(method, path, body, result); |
|
| 400 |
+ logWriteFailure(method, path, body, form, result); |
|
| 357 | 401 |
throw new BackendRequestError(result); |
| 358 | 402 |
} |
| 359 | 403 |
|
... | ... | @@ -365,7 +409,7 @@ |
| 365 | 409 |
payload: ContentWritePayload, |
| 366 | 410 |
cntntsType: string |
| 367 | 411 |
): Promise<string | null> {
|
| 368 |
- return sendWrite(CONTENT_BASE_PATH, 'POST', { ...payload, cntntsType });
|
|
| 412 |
+ return sendWrite(CONTENT_BASE_PATH, 'POST', undefined, toFormParams({ ...payload, cntntsType }));
|
|
| 369 | 413 |
} |
| 370 | 414 |
|
| 371 | 415 |
export async function updateContent( |
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?