@fuzionx/framework 0.1.75 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.75",
12
+ "@fuzionx/player": "^0.1.77",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"
@@ -12,6 +12,7 @@ import { promises as fs } from 'node:fs';
12
12
  import cluster from 'node:cluster';
13
13
  import Config from './Config.js';
14
14
  import Context, { createContext, createContextFromReq } from './Context.js';
15
+ import { ServiceError } from './AppError.js';
15
16
  import Router from '../http/Router.js';
16
17
  import ModelRegistry from '../database/ModelRegistry.js';
17
18
  import ErrorHandler from '../http/ErrorHandler.js';
@@ -299,8 +300,50 @@ export default class Application {
299
300
  await this.i18n.load();
300
301
 
301
302
  // 3. Scheduler / Queue / Telegram / Storage / Cache 초기화
302
- this._scheduler = new Scheduler(this);
303
- this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
303
+ //
304
+ // yaml 구조:
305
+ // app.scheduler.enabled / .lock.{driver,redis_url,prefix}
306
+ // app.queue.{driver,redis_url,prefix}
307
+ // fuzionx 기존 코드는 top-level (config.get('queue')) 였지만 실제로는
308
+ // app.* 아래에 있음 — fallback 둘 다 지원.
309
+ const queueCfg = this.config.get('app.queue') || this.config.get('queue') || {};
310
+ const schedulerCfg = this.config.get('app.scheduler') || this.config.get('scheduler') || {};
311
+ const lockCfg = schedulerCfg.lock || {};
312
+
313
+ // Scheduler — leader election 용 redis URL
314
+ // 우선순위: app.scheduler.lock.redis_url → app.queue.redis_url 재사용
315
+ // lock.driver !== 'redis' 면 leader election 비활성 (fallback)
316
+ const schedulerLockEnabled = lockCfg.driver === 'redis';
317
+ const schedulerRedisUrl = schedulerLockEnabled
318
+ ? (lockCfg.redis_url || queueCfg.redis_url || null)
319
+ : null;
320
+ this._scheduler = new Scheduler(this, {
321
+ url: schedulerRedisUrl,
322
+ prefix: lockCfg.prefix || 'fuzionx:scheduler',
323
+ });
324
+ this._schedulerEnabled = schedulerCfg.enabled !== false; // 기본 true
325
+ if (this._schedulerEnabled) {
326
+ try {
327
+ await this._scheduler.connect();
328
+ } catch (e) {
329
+ this.logger.warn(`[Scheduler] connect failed: ${e.message}`);
330
+ }
331
+ } else {
332
+ this.logger.info('[Scheduler] disabled (app.scheduler.enabled=false)');
333
+ }
334
+
335
+ this._queue = new Queue(this, {
336
+ driver: queueCfg.driver || 'memory',
337
+ url: queueCfg.redis_url,
338
+ prefix: queueCfg.prefix,
339
+ brpopTimeoutSec: queueCfg.brpop_timeout_sec,
340
+ promoterIntervalMs: queueCfg.promoter_interval_ms,
341
+ });
342
+ try {
343
+ await this._queue.start();
344
+ } catch (e) {
345
+ this.logger.error(`[Queue] start failed: ${e.message}`);
346
+ }
304
347
 
305
348
  // 텔레그램 매니저 로드 (Queue 기반이므로 _queue 설정 직후 구성)
306
349
  const telegramConfig = this.config.get('app.telegram');
@@ -530,8 +573,11 @@ export default class Application {
530
573
 
531
574
  // Scheduler 시작 — primary 프로세스에서만 (워커 중복 실행 방지)
532
575
  // fuzionx 상위 레이어가 cluster.fork()로 워커를 생성하므로
533
- // cluster.isPrimary로 단일 프로세스 보장
534
- if (this._scheduler && this._scheduler._jobs.length > 0 && !cluster.isWorker) {
576
+ // cluster.isPrimary로 단일 프로세스 보장 — multi-worker 차원 보호.
577
+ // 추가로 redis leader election (Scheduler.connect) multi-server 차원 보호.
578
+ // app.scheduler.enabled=false 면 전체 스킵.
579
+ if (this._scheduler && this._scheduler._jobs.length > 0
580
+ && !cluster.isWorker && this._schedulerEnabled !== false) {
535
581
  this._scheduler.start();
536
582
  }
537
583
 
@@ -709,6 +755,14 @@ export default class Application {
709
755
 
710
756
  // 미들웨어 체인 사전 구성 (부트 시 1회)
711
757
  const middlewareFns = [...this._globalMiddleware];
758
+
759
+ // route.validate 옵션 → Validation 미들웨어를 라우트별 미들웨어 *앞*에 삽입
760
+ // (글로벌 미들웨어 이후, 라우트 미들웨어 이전 — 입력 정규화는 인증/권한 검사보다 늦게,
761
+ // 핸들러 진입 직전에 — 이 순서를 따르도록 라우트 미들웨어 앞에 둠)
762
+ if (route.validate) {
763
+ middlewareFns.push(this._createValidateMiddleware(route.validate));
764
+ }
765
+
712
766
  if (route.middleware?.length) {
713
767
  for (const mw of route.middleware) {
714
768
  if (typeof mw === 'function') {
@@ -783,8 +837,12 @@ export default class Application {
783
837
  _handleRawRequest(rawReq) {
784
838
  const dispatch = this._rawDispatch[rawReq.handlerId];
785
839
  if (!dispatch) {
786
- // handlerId 미등록404
787
- return { status: 404, body: '{"error":"Not Found"}', headers: { 'Content-Type': 'application/json' } };
840
+ // 라우트 미매칭ErrorHandler 합류 (Accept 협상)
841
+ return this._buildErrorResponse(
842
+ rawReq,
843
+ this._getDefaultAppName(),
844
+ new ServiceError('Not Found', 404),
845
+ );
788
846
  }
789
847
 
790
848
  // ── 도메인 검증 (모든 요청에 대해 수행) ──
@@ -794,11 +852,11 @@ export default class Application {
794
852
  // 미등록 도메인 → 403 차단
795
853
  if (!resolvedAppName) {
796
854
  const reqHost = rawReq.headers?.host || 'unknown';
797
- return {
798
- status: 403,
799
- body: JSON.stringify({ error: 'Forbidden', message: `도메인 '${reqHost}'은(는) 등록되지 않았습니다.` }),
800
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
801
- };
855
+ return this._buildErrorResponse(
856
+ rawReq,
857
+ this._getDefaultAppName(),
858
+ new ServiceError(`도메인 '${reqHost}'은(는) 등록되지 않았습니다.`, 403),
859
+ );
802
860
  }
803
861
 
804
862
  // ── 앱 결정 (단일 앱: O(0), 멀티앱: Host 기반) ──
@@ -810,11 +868,11 @@ export default class Application {
810
868
  appName = resolvedAppName;
811
869
  entry = dispatch.appMap.get(appName);
812
870
  if (!entry) {
813
- return {
814
- status: 403,
815
- body: JSON.stringify({ error: 'Forbidden', message: `앱 '${appName}'을(를) 찾을 수 없습니다.` }),
816
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
817
- };
871
+ return this._buildErrorResponse(
872
+ rawReq,
873
+ appName,
874
+ new ServiceError(`앱 '${appName}'을(를) 찾을 없습니다.`, 403),
875
+ );
818
876
  }
819
877
  }
820
878
 
@@ -826,6 +884,8 @@ export default class Application {
826
884
  ctx.appName = appName;
827
885
 
828
886
  // ── Sync-first 실행 ──
887
+ // 핸들러/미들웨어 throw → _handleContextError 가 ErrorHandler 로깅까지 책임지므로
888
+ // 여기서는 별도 console 출력 없이 ctx 응답만 만든다 (중복 로그 방지).
829
889
  let chainResult;
830
890
  try {
831
891
  if (middlewareFns.length === 0) {
@@ -835,7 +895,6 @@ export default class Application {
835
895
  chainResult = this._executeSyncChain(middlewareFns, ctx, resolvedHandler);
836
896
  }
837
897
  } catch (err) {
838
- console.error(`[FX] Unhandled controller error: ${ctx.method} ${ctx.path}`, err);
839
898
  this._handleContextError(err, ctx);
840
899
  return ctx._toFusionResponse();
841
900
  }
@@ -851,12 +910,11 @@ export default class Application {
851
910
  this._sendContextResponse(rawReq.requestId, ctx);
852
911
  })
853
912
  .catch((err) => {
854
- console.error(`[FX] Unhandled controller error: ${ctx.method} ${ctx.path}`, err);
855
913
  try {
856
914
  this._handleContextError(err, ctx);
857
915
  } catch (handlerErr) {
858
- // 에러 핸들링 자체 실패 → 최소한의 500 응답 보장
859
- console.error(`[FX] Error handler failed:`, handlerErr);
916
+ // 에러 핸들링 자체 실패 → 최소한의 500 응답 보장 (logger도 실패 가능성 → console 유지)
917
+ this.logger?.error?.('[FX] ErrorHandler 자체 실패:', handlerErr);
860
918
  try {
861
919
  ctx._statusCode = 500;
862
920
  ctx._body = JSON.stringify({ error: { message: err.message, status: 500 } });
@@ -937,6 +995,11 @@ export default class Application {
937
995
  _registerWsHandlers(coreApp) {
938
996
  if (!coreApp.ws) return;
939
997
 
998
+ // WS connect/message/disconnect 추적 로그는 채팅 트래픽 등에서 폭주하므로
999
+ // bridge.logging.ws_trace 옵션이 명시적으로 true일 때만 출력.
1000
+ const wsTrace = !!this.config.get('bridge.logging.ws_trace', false);
1001
+ const logger = this.logger;
1002
+
940
1003
  for (const [, appEntry] of this._appRegistry) {
941
1004
  if (!appEntry.wsHandlers || appEntry.wsHandlers.size === 0) continue;
942
1005
 
@@ -948,7 +1011,7 @@ export default class Application {
948
1011
  const inst = new HandlerClass(this);
949
1012
 
950
1013
  wsNs.on('connect', (socket) => {
951
- this.logger.debug(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
1014
+ if (wsTrace) logger.debug(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
952
1015
  inst.onConnect(socket);
953
1016
  });
954
1017
 
@@ -958,13 +1021,13 @@ export default class Application {
958
1021
  catch { parsed = { type: 'message', data: rawMessage }; }
959
1022
  const eventType = parsed.type || 'message';
960
1023
  const eventData = parsed.data || parsed;
961
- this.logger.debug(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
1024
+ if (wsTrace) logger.debug(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
962
1025
  const entry = eventMap.get(eventType);
963
1026
  if (entry) {
964
1027
  const result = entry.handler.call(inst, socket, eventData);
965
1028
  if (result && typeof result.then === 'function') {
966
1029
  result.then(r => { if (r) socket.send(JSON.stringify(r)); })
967
- .catch(e => console.error(`[WS] error: ${eventType}`, e));
1030
+ .catch(e => logger.error(`[WS] error: ${eventType}`, e));
968
1031
  } else if (result) {
969
1032
  socket.send(JSON.stringify(result));
970
1033
  }
@@ -974,14 +1037,20 @@ export default class Application {
974
1037
  });
975
1038
 
976
1039
  wsNs.on('disconnect', (socket) => {
977
- this.logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
1040
+ if (wsTrace) logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
978
1041
  inst.onDisconnect(socket);
979
1042
  });
980
1043
 
981
1044
  // 원격 메타데이터 변경 이벤트 (Hub METADATA_UPDATE → onRemoteMetadataUpdate)
982
- wsNs.on('metadata_update', (socket, key, value) => {
983
- inst.onRemoteMetadataUpdate(socket, key, value);
984
- });
1045
+ // core가 이 이벤트를 지원하지 않으면 ( 버전) 조용히 건너뛴다 — 부팅 실패 방지.
1046
+ // Hub 멀티서버 메타데이터 동기화 기능을 쓰지 않으면 영향 없음.
1047
+ try {
1048
+ wsNs.on('metadata_update', (socket, key, value) => {
1049
+ inst.onRemoteMetadataUpdate(socket, key, value);
1050
+ });
1051
+ } catch (e) {
1052
+ logger.debug(`[WS] '${namespace}' metadata_update 미지원 — 건너뜀 (${e.message})`);
1053
+ }
985
1054
 
986
1055
  this.logger.info(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
987
1056
  }
@@ -1265,6 +1334,85 @@ export default class Application {
1265
1334
  }
1266
1335
  }
1267
1336
 
1337
+ /**
1338
+ * route.validate 옵션 → 미들웨어로 변환.
1339
+ *
1340
+ * 미들웨어 체인에서 실행되며, ValidationError 발생 시 _handleContextError 경로를 탐.
1341
+ * Validation 인스턴스(Joi 기반)는 lazy 생성 — 첫 호출 시에만 joi import.
1342
+ * Joi가 사용자 프로젝트에 설치되지 않은 경우 검증을 건너뛰고 경고를 1회 출력한다.
1343
+ *
1344
+ * @param {object} validateOpts - { body?, query?, params? } Joi 스키마 또는 간단문법 객체
1345
+ * @returns {Function} async 미들웨어 (ctx, next) => Promise<void>
1346
+ * @private
1347
+ */
1348
+ _createValidateMiddleware(validateOpts) {
1349
+ return async (ctx, next) => {
1350
+ const runner = await this._getValidationRunner();
1351
+ if (!runner) {
1352
+ // Joi 없음 → 검증 건너뜀 (경고는 _getValidationRunner에서 1회만 출력)
1353
+ return next();
1354
+ }
1355
+ const result = runner.run(ctx, validateOpts);
1356
+ // 검증된 데이터(stripUnknown 포함)를 ctx에 반영
1357
+ if (result.body !== undefined) ctx.body = result.body;
1358
+ if (result.query !== undefined) ctx.query = result.query;
1359
+ if (result.params !== undefined) ctx.params = result.params;
1360
+ await next();
1361
+ };
1362
+ }
1363
+
1364
+ /**
1365
+ * Validation 러너 lazy 가져오기 — Joi import + 인스턴스 캐싱.
1366
+ *
1367
+ * @returns {Promise<import('../http/Validation.js').default|null>}
1368
+ * @private
1369
+ */
1370
+ async _getValidationRunner() {
1371
+ if (this._validationRunner !== undefined) return this._validationRunner;
1372
+ try {
1373
+ const joiMod = await import('joi');
1374
+ const Joi = joiMod.default || joiMod;
1375
+ const Validation = (await import('../http/Validation.js')).default;
1376
+ this._validationRunner = new Validation(Joi);
1377
+ } catch (e) {
1378
+ // joi 미설치 → 검증 건너뜀. 경고는 1회.
1379
+ this.logger.warn(
1380
+ `[Validation] joi가 설치되지 않아 route.validate 옵션이 무시됩니다 — ${e.message}`,
1381
+ );
1382
+ this._validationRunner = null;
1383
+ }
1384
+ return this._validationRunner;
1385
+ }
1386
+
1387
+ /**
1388
+ * 디스패치 이전(라우트 미매칭/도메인 거부 등) 발생한 에러용 응답 생성기.
1389
+ *
1390
+ * 미들웨어 체인 없이 즉시 ErrorHandler로 흘려서 Accept 협상을 통과시킴.
1391
+ * 동기 경로이므로 Fusion 응답을 그대로 반환한다.
1392
+ *
1393
+ * @param {object} rawReq - Bridge 원시 요청
1394
+ * @param {string} appName - 응답에 적용할 앱 이름(테마/뷰 lookup용)
1395
+ * @param {Error} err - ServiceError 또는 일반 Error
1396
+ * @returns {object} Fusion 응답 객체
1397
+ * @private
1398
+ */
1399
+ _buildErrorResponse(rawReq, appName, err) {
1400
+ // async-safe 새 인스턴스 (sync 공유 인스턴스 오염 방지 — 이 경로는 cold path)
1401
+ const ctx = createContext(rawReq, this, false);
1402
+ ctx.appName = appName || this._getDefaultAppName();
1403
+ try {
1404
+ this._handleContextError(err, ctx);
1405
+ } catch (handlerErr) {
1406
+ // 에러 핸들링 자체 실패 → 최소 500 응답 보장
1407
+ this.logger.error('[FX] ErrorHandler 자체 실패:', handlerErr);
1408
+ ctx._statusCode = 500;
1409
+ ctx._body = JSON.stringify({ error: { message: err?.message || 'Internal Error', status: 500 } });
1410
+ ctx._headers['Content-Type'] = 'application/json; charset=utf-8';
1411
+ ctx._sent = true;
1412
+ }
1413
+ return ctx._toFusionResponse();
1414
+ }
1415
+
1268
1416
  /**
1269
1417
  * Context 에러 핸들링 — ctx에 에러 응답 설정
1270
1418
  *
@@ -1335,7 +1483,7 @@ export default class Application {
1335
1483
  response.body || '', contentType, extraHeaders,
1336
1484
  );
1337
1485
  } catch (e) {
1338
- console.error('[fuzionx] sendAsyncResponse failed:', e.message);
1486
+ this.logger.error('[fuzionx] sendAsyncResponse 실패:', e.message);
1339
1487
  }
1340
1488
  }
1341
1489
 
@@ -1390,7 +1538,7 @@ export default class Application {
1390
1538
  await this.emit('shutting-down', { signal });
1391
1539
 
1392
1540
  // 1. 스케줄러 정지 (04-bootstrap-lifecycle.md)
1393
- if (this._scheduler) this._scheduler.stop();
1541
+ if (this._scheduler) await this._scheduler.stop();
1394
1542
 
1395
1543
  // 2. 큐 drain — 처리 중 Task 완료 대기 (doc 04 순서 3)
1396
1544
  if (this._queue && this._queue.pending > 0) {
package/lib/core/Base.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { ServiceError } from './AppError.js';
2
+
1
3
  /**
2
4
  * Base — 공통 기본 클래스
3
5
  *
@@ -82,4 +84,15 @@ export default class Base {
82
84
 
83
85
  /** @returns {import('../cache/CacheManager.js').default|null} 캐시 매니저 */
84
86
  get cache() { return this.app._cacheManager || null; }
87
+
88
+ /**
89
+ * 비즈니스 로직 에러 생성 헬퍼
90
+ * @param {string} message - 에러 메시지
91
+ * @param {number} [status=400] - HTTP 상태 코드
92
+ * @param {object|null} [data=null] - 추가 데이터
93
+ * @returns {import('./AppError.js').ServiceError}
94
+ */
95
+ error(message, status = 400, data = null) {
96
+ return new ServiceError(message, status, data);
97
+ }
85
98
  }
@@ -486,7 +486,12 @@ function _createSession(rawReq, app) {
486
486
  data[key] = value;
487
487
  if (sessionId && bridge?.sessionSet) {
488
488
  try { bridge.sessionSet(sessionId, { ...data }); }
489
- catch (e) { console.error('[Session] sessionSet failed:', e?.message || e); }
489
+ catch (e) {
490
+ // app.logger가 있으면 거기로, 없으면 stderr 보장 차원에서 console.error 폴백
491
+ const msg = e?.message || e;
492
+ if (app?.logger?.warn) app.logger.warn('[Session] sessionSet 실패:', msg);
493
+ else console.error('[Session] sessionSet failed:', msg);
494
+ }
490
495
  }
491
496
  },
492
497
 
@@ -52,28 +52,55 @@ export default class ErrorHandler {
52
52
  return this._htmlResponse(err, ctx, status);
53
53
  }
54
54
 
55
+ /**
56
+ * ValidationError(또는 422) HTML 컨텍스트 처리:
57
+ * - ctx.session.flash('errors', fields) + flash('old', body)
58
+ * - Referer로 리다이렉트(없으면 '/')
59
+ * 세션이 없거나 flash 호출 자체가 실패하면 false 반환 → 일반 HTML 페이지 폴백.
60
+ *
61
+ * @param {ValidationError} err
62
+ * @param {object} ctx
63
+ * @returns {boolean} 처리 성공 여부
64
+ * @private
65
+ */
66
+ _tryFlashRedirect(err, ctx) {
67
+ const flash = ctx.session?.flash;
68
+ if (typeof flash !== 'function') return false;
69
+ try {
70
+ flash.call(ctx.session, 'errors', err.fields || {});
71
+ if (ctx.body && typeof ctx.body === 'object') {
72
+ flash.call(ctx.session, 'old', ctx.body);
73
+ }
74
+ const back = (typeof ctx.get === 'function' && ctx.get('referer')) || '/';
75
+ ctx.redirect(back);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
55
82
  /** @private */
56
83
  _jsonResponse(err, ctx, status) {
84
+ const t = typeof ctx.t === 'function' ? ctx.t.bind(ctx) : (msg) => msg;
85
+ const translatedMessage = t(err.message) || err.message;
86
+
57
87
  const body = {
58
- error: {
59
- message: err.message,
60
- status,
61
- },
88
+ error: translatedMessage,
62
89
  };
63
90
 
64
91
  // ValidationError → 필드 에러 포함
65
92
  if (err instanceof ValidationError) {
66
- body.error.fields = err.fields;
93
+ body.fields = err.fields;
67
94
  }
68
95
 
69
96
  // dev 모드에서 스택 노출
70
97
  if (this.isDev && status >= 500) {
71
- body.error.stack = err.stack;
98
+ body.stack = err.stack;
72
99
  }
73
100
 
74
101
  // 추가 데이터
75
102
  if (err.data && !(err instanceof ValidationError)) {
76
- body.error.data = err.data;
103
+ body.data = err.data;
77
104
  }
78
105
 
79
106
  ctx.status(status).json(body);
@@ -87,9 +114,18 @@ export default class ErrorHandler {
87
114
  * 2. views/{theme}/errors/default.html
88
115
  * 3. 프레임워크 내장 에러 페이지
89
116
  *
117
+ * View.render는 템플릿이 없으면 throw하므로 try/catch만으로 폴백 가능.
118
+ * (이전 휴리스틱 `<!-- template:` 검사는 Tera 출력에 존재하지 않아 무용 — 제거)
119
+ *
90
120
  * @private
91
121
  */
92
122
  _htmlResponse(err, ctx, status) {
123
+ // ── ValidationError + 세션 사용 가능 → 플래시 + 이전 페이지로 리다이렉트 (폼 흐름) ──
124
+ // docs/framework/08-error-handling.md 명세: HTML 컨텍스트의 422는 redirect back
125
+ if (err instanceof ValidationError && this._tryFlashRedirect(err, ctx)) {
126
+ return;
127
+ }
128
+
93
129
  const errorData = {
94
130
  error: {
95
131
  code: status,
@@ -104,23 +140,22 @@ export default class ErrorHandler {
104
140
  const appEntry = ctx.app?._appRegistry?.get(ctx.appName);
105
141
  const view = this._view || appEntry?.view;
106
142
  if (view) {
107
- const theme = ctx.theme || ctx.app?.config?.get('themes.default', 'default') || 'default';
108
143
  // 1. views/{theme}/errors/{code}.html
109
144
  try {
110
145
  const html = view.render(`errors/${status}`, errorData);
111
- if (html && !html.startsWith('<!-- template:')) {
146
+ if (html) {
112
147
  ctx.status(status).html(html);
113
148
  return;
114
149
  }
115
- } catch {}
150
+ } catch { /* 템플릿 없음 → 다음 폴백 */ }
116
151
  // 2. views/{theme}/errors/default.html
117
152
  try {
118
153
  const html = view.render('errors/default', errorData);
119
- if (html && !html.startsWith('<!-- template:')) {
154
+ if (html) {
120
155
  ctx.status(status).html(html);
121
156
  return;
122
157
  }
123
- } catch {}
158
+ } catch { /* 템플릿 없음 → 내장 에러 페이지로 폴백 */ }
124
159
  }
125
160
 
126
161
  // 3. 프레임워크 내장 에러 페이지 (모던 디자인 — XSS 방지)
@@ -7,9 +7,20 @@
7
7
  */
8
8
  import { ValidationError } from '../core/AppError.js';
9
9
 
10
+ /** type 룰: base schema 를 결정 (modifier 와 분리해 순서 무관 처리) */
11
+ const TYPE_RULES = new Set(['string', 'number', 'integer', 'boolean', 'date', 'array']);
12
+
10
13
  /**
11
14
  * 간단 규칙 문자열을 Joi 스키마로 변환
12
- * @param {object} rules - { name: 'required|min:2', email: 'required|email' }
15
+ *
16
+ * 규칙은 두 그룹으로 분리되어 처리된다:
17
+ * 1) type 룰 (string/number/integer/boolean/date/array) — base schema 결정
18
+ * 2) modifier 룰 (required/optional/nullable/min/max/email/url/in/positive/alpha)
19
+ *
20
+ * type 룰을 먼저 적용하고 그 위에 modifier 를 쌓는 방식이라
21
+ * 'required|string' / 'string|required' 어느 순서로 써도 동일하게 동작한다.
22
+ *
23
+ * @param {object} rules - { name: 'required|min:2', email: 'string|email|nullable' }
13
24
  * @param {object} Joi - Joi 모듈 참조
14
25
  * @returns {import('joi').ObjectSchema}
15
26
  */
@@ -18,41 +29,62 @@ export function parseRules(rules, Joi) {
18
29
 
19
30
  for (const [field, rule] of Object.entries(rules)) {
20
31
  const parts = typeof rule === 'string' ? rule.split('|') : [];
21
- let schema = Joi.any();
22
32
 
33
+ // 1단계: type 룰과 modifier 룰 분리 (type 은 마지막 선언 우선)
34
+ let typeName = null;
35
+ const modifiers = [];
23
36
  for (const part of parts) {
37
+ const [name] = part.split(':');
38
+ if (TYPE_RULES.has(name)) typeName = name;
39
+ else modifiers.push(part);
40
+ }
41
+
42
+ // 2단계: base schema 결정
43
+ let schema;
44
+ switch (typeName) {
45
+ case 'string': schema = Joi.string(); break;
46
+ case 'number': schema = Joi.number(); break;
47
+ case 'integer': schema = Joi.number().integer(); break;
48
+ case 'boolean': schema = Joi.boolean(); break;
49
+ case 'date': schema = Joi.date(); break;
50
+ case 'array': schema = Joi.array(); break;
51
+ default: schema = Joi.any();
52
+ }
53
+
54
+ // 3단계: modifier 적용 (type 추론 가능한 룰은 base 가 any 이면 자동 승격)
55
+ for (const part of modifiers) {
24
56
  const [name, ...args] = part.split(':');
25
57
 
26
58
  switch (name) {
27
- case 'string':
28
- schema = Joi.string(); break;
29
- case 'number':
30
- schema = Joi.number(); break;
31
- case 'boolean':
32
- schema = Joi.boolean(); break;
33
59
  case 'required':
34
60
  schema = schema.required(); break;
35
61
  case 'optional':
36
62
  schema = schema.optional(); break;
63
+ case 'nullable':
64
+ // null 허용 — 클라이언트가 미입력 필드를 null 로 보내는 패턴 지원
65
+ schema = schema.allow(null); break;
66
+ case 'empty':
67
+ // 빈 문자열 허용 — Joi.string() 은 기본적으로 '' 를 거부하므로
68
+ // 폼이 미입력 필드를 빈 문자열로 보내는 경우 명시적 허용 필요
69
+ schema = schema.allow(''); break;
37
70
  case 'min':
38
71
  schema = schema.min(Number(args[0])); break;
39
72
  case 'max':
40
73
  schema = schema.max(Number(args[0])); break;
41
74
  case 'email':
42
- // 기존 schema Joi.any() string()으로 교체, 이미 string()이면 유지
43
- schema = (schema.type === 'any' ? Joi.string() : schema).email(); break;
75
+ if (schema.type === 'any') schema = Joi.string();
76
+ schema = schema.email(); break;
44
77
  case 'url':
45
- schema = (schema.type === 'any' ? Joi.string() : schema).uri(); break;
46
- case 'in':
47
- schema = schema.valid(...args[0].split(',')); break;
48
- case 'integer':
49
- schema = schema.integer ? schema.integer() : Joi.number().integer(); break;
78
+ if (schema.type === 'any') schema = Joi.string();
79
+ schema = schema.uri(); break;
50
80
  case 'positive':
51
- schema = schema.positive ? schema.positive() : Joi.number().positive(); break;
52
- case 'date':
53
- schema = schema.type === 'any' ? Joi.date() : schema; break;
81
+ if (schema.type === 'any') schema = Joi.number();
82
+ schema = schema.positive(); break;
54
83
  case 'alpha':
55
- schema = (schema.type === 'any' ? Joi.string() : schema).alphanum(); break;
84
+ if (schema.type === 'any') schema = Joi.string();
85
+ schema = schema.alphanum(); break;
86
+ case 'in':
87
+ schema = schema.valid(...args[0].split(',')); break;
56
88
  default:
57
89
  // 알 수 없는 규칙은 무시
58
90
  break;
@@ -86,13 +118,13 @@ export default class Validation {
86
118
  const result = {};
87
119
 
88
120
  if (validateOpts.body) {
89
- result.body = this._validate(ctx.body, validateOpts.body);
121
+ result.body = this._validate(ctx, ctx.body, validateOpts.body);
90
122
  }
91
123
  if (validateOpts.query) {
92
- result.query = this._validate(ctx.query, validateOpts.query);
124
+ result.query = this._validate(ctx, ctx.query, validateOpts.query);
93
125
  }
94
126
  if (validateOpts.params) {
95
- result.params = this._validate(ctx.params, validateOpts.params);
127
+ result.params = this._validate(ctx, ctx.params, validateOpts.params);
96
128
  }
97
129
 
98
130
  return result;
@@ -101,7 +133,7 @@ export default class Validation {
101
133
  /**
102
134
  * @private
103
135
  */
104
- _validate(data, schema) {
136
+ _validate(ctx, data, schema) {
105
137
  // 간단문법 객체 → Joi 변환
106
138
  if (schema && typeof schema === 'object' && !schema.validate) {
107
139
  schema = parseRules(schema, this._Joi);
@@ -114,8 +146,19 @@ export default class Validation {
114
146
 
115
147
  if (error) {
116
148
  const fields = {};
149
+ const translate = typeof ctx.t === 'function' ? ctx.t : () => '';
150
+
117
151
  for (const detail of error.details) {
118
- fields[detail.path.join('.')] = detail.message;
152
+ // 1. 필드명 다국어 번역 시도 (예: username -> 아이디)
153
+ // fields.username 키가 없으면 원래 영문 필드명 사용
154
+ const fieldName = translate(`fields.${detail.context.key}`) || detail.context.key;
155
+
156
+ // 2. Joi 에러 타입별 다국어 매핑 (예: any.required -> {field}은(는) 필수입니다)
157
+ // detail.context 에는 limit, val 등의 추가 제약 정보가 포함되어 있음
158
+ const i18nMsg = translate(`validation.${detail.type}`, { ...detail.context, field: fieldName });
159
+
160
+ // 3. 번역된 메세지가 없으면 Joi의 기본 영문 메세지 fallback
161
+ fields[detail.path.join('.')] = i18nMsg || detail.message;
119
162
  }
120
163
  throw new ValidationError('Validation failed', fields);
121
164
  }