mega-framework 0.1.8 → 0.1.10

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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/apps/main/controllers/jobs-controller.js +22 -2
  3. package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
  4. package/sample/crud/apps/main/jobs/email-job.js +37 -2
  5. package/sample/crud/apps/main/locales/server/en.json +5 -0
  6. package/sample/crud/apps/main/locales/server/ko.json +5 -0
  7. package/sample/crud/apps/main/routes/upload.js +20 -1
  8. package/sample/crud/apps/main/services/guide-service.js +4 -3
  9. package/sample/crud/apps/main/services/jobs-demo-service.js +1 -1
  10. package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
  11. package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
  12. package/sample/crud/apps/main/views/upload/index.ejs +4 -1
  13. package/sample/crud/docs/guide/01-cli.md +587 -0
  14. package/sample/crud/docs/guide/02-router-controller.md +497 -0
  15. package/sample/crud/docs/guide/03-service-model-db.md +929 -0
  16. package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
  17. package/sample/crud/docs/guide/05-scheduler-job-worker.md +425 -0
  18. package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
  19. package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
  20. package/sample/crud/docs/guide/08-observability.md +373 -0
  21. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
  22. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
  23. package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
  24. package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
  25. package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
  26. package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
  27. package/src/cli/index.js +41 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -8,7 +8,9 @@
8
8
  import { currentUser } from '../middleware/web-auth.js'
9
9
 
10
10
  /** 발송 시뮬레이션 모드 화이트리스트(폼 입력 검증). */
11
- const MODES = ['ok', 'flaky', 'fail']
11
+ const MODES = ['ok', 'flaky', 'fail', 'hang']
12
+ /** 'hang' 모드 지연 상한(ms) — 폼 입력 검증(10분). 잡 클래스 timeoutMs(5s)와 별개의 입력 가드. */
13
+ const MAX_DELAY_MS = 600_000
12
14
  /** 알림 쿼리(?notice=) → 로케일 키 화이트리스트. */
13
15
  const NOTICE_KEYS = new Set(['enqueued'])
14
16
 
@@ -37,7 +39,25 @@ export class JobsController {
37
39
  const to = rawTo.length > 0 ? rawTo : 'demo@example.com'
38
40
  // 잡 식별자 — 시도 카운터/이벤트 키에 쓰인다. 같은 페이로드 중복 enqueue 도 서로 구분되게 유니크하게 만든다.
39
41
  const id = `email-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
40
- await ctx.services.jobsDemo.enqueue({ id, to, mode })
42
+ /** @type {{ id: string, to: string, mode: string, delayMs?: number }} */
43
+ const payload = { id, to, mode }
44
+ // hang 모드만 delayMs 를 싣는다. 미입력·무효(<=0)면 생략 → 잡 클래스가 기본 지연(8s)으로 timeout 을 시연.
45
+ if (mode === 'hang') {
46
+ const delayMs = JobsController.#parseDelayMs(body.delayMs)
47
+ if (delayMs > 0) payload.delayMs = delayMs
48
+ }
49
+ await ctx.services.jobsDemo.enqueue(payload)
41
50
  return reply.redirect('/demo/jobs?notice=enqueued')
42
51
  }
52
+
53
+ /**
54
+ * 폼 delayMs(문자열) → [0, MAX_DELAY_MS] 정수. 비정수·음수는 0 으로, 초과는 상한으로 보정한다. 폼 POST 라
55
+ * AJV body 스키마 대신 컨트롤러에서 검증한다(기존 mode/to 처리와 동일 스타일 — _csrf/AJV 순서 이슈 회피).
56
+ * @param {unknown} raw @returns {number} 0 = 미지정(잡 클래스 기본 지연 사용).
57
+ */
58
+ static #parseDelayMs(raw) {
59
+ const n = Number.parseInt(typeof raw === 'string' ? raw : String(raw ?? ''), 10)
60
+ if (!Number.isFinite(n) || n <= 0) return 0
61
+ return Math.min(n, MAX_DELAY_MS)
62
+ }
43
63
  }
@@ -8,8 +8,10 @@
8
8
  * 시점에 미파싱) 업로드는 `upload-demo.js` 의 fetch + FormData 로 제출한다.
9
9
  *
10
10
  * 다운로드(GET /demo/upload/file/:name)는 **정적 서빙이 아니라 소스에서 파일을 읽어 전송**한다 — 인증
11
- * (`webRequireAuth`, 라우트 가드)된 사용자만 받을 수 있다(가드를 떼면 공개로 전환 가능). 파일명은 basename +
12
- * 디렉터리 내부 검증으로 경로 탐색을 막는다.
11
+ * (`webRequireAuth`, 라우트 가드)된 사용자만 받을 수 있다(가드를 떼면 공개로 전환 가능). `:name` **실제
12
+ * 저장 파일명**(코어 `uniquifyFilename` `-<ts36>-<rand8hex>` 접미 포함, ADR-187)이고, 받는 쪽에 제안할
13
+ * 표시 파일명은 `?as=` 로 따로 받는다(Content-Disposition, RFC 6266/5987 — 한글·공백·괄호 대응). 파일명은
14
+ * basename + 디렉터리 내부 검증으로 경로 탐색을 막는다.
13
15
  *
14
16
  * 저장 디렉터리는 **설정(.env `DEMO_UPLOAD_DIR`)** 으로 받는다(미설정 시 `var/uploads`). 상대경로는 프로젝트
15
17
  * 루트(`process.cwd()`, views.dir/staticAssets.dir 과 같은 규약 ADR-151) 기준으로 해석한다.
@@ -43,6 +45,21 @@ function uploadDir() {
43
45
  return isAbsolute(UPLOAD_DIR_SETTING) ? UPLOAD_DIR_SETTING : join(process.cwd(), UPLOAD_DIR_SETTING)
44
46
  }
45
47
 
48
+ /**
49
+ * Content-Disposition: attachment 헤더 값 생성(RFC 6266/5987). 비ASCII 파일명은 quoted-string(`filename=`)에
50
+ * 실을 수 없으므로 ASCII 폴백과 UTF-8 퍼센트 인코딩(`filename*=`)을 함께 보낸다 — 한글·공백·괄호 파일명 대응.
51
+ * @param {string} name - 받는 쪽에 제안할 파일명(경로 성분 없음).
52
+ * @returns {string}
53
+ */
54
+ function contentDispositionAttachment(name) {
55
+ // RFC 5987 attr-char 밖 문자는 퍼센트 인코딩 — encodeURIComponent 가 남기는 !'()* 중 '()* 도 추가 인코딩
56
+ // (MDN encodeRFC5987ValueChars 패턴).
57
+ const utf8 = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
58
+ // 구식 filename= 폴백 — 비ASCII·따옴표·백슬래시는 '_' 치환(quoted-string 이 깨지지 않게).
59
+ const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
60
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`
61
+ }
62
+
46
63
  export class UploadController {
47
64
  /** GET /demo/upload — 업로드 폼 + 최근 업로드 목록 렌더. @param {any} req @param {any} reply @param {any} ctx */
48
65
  static async index(req, reply, ctx) {
@@ -61,8 +78,11 @@ export class UploadController {
61
78
  // saveUploads 가 MIME 비허용(415)·크기 초과(413)면 throw → 글로벌 핸들러가 에러 envelope 로 응답한다.
62
79
  const saved = await req.saveUploads(uploadDir())
63
80
  // 저장 경로는 프로젝트 루트 기준 상대경로로 환산(서버 절대경로 노출 회피, 데모에선 위치 확인이 목적).
81
+ // savedName 은 디스크의 실제 파일명(유일화 접미 포함) — 다운로드 URL 의 키다. filename(표시명)만으론
82
+ // 디스크 파일을 못 찾는다(코어가 저장 파일명을 uniquifyFilename 으로 유일화, ADR-187).
64
83
  const files = saved.map((/** @type {any} */ f) => ({
65
84
  filename: f.filename,
85
+ savedName: basename(f.savedAs),
66
86
  bytes: f.bytes,
67
87
  mimetype: f.mimetype,
68
88
  path: relative(process.cwd(), f.savedAs),
@@ -71,7 +91,7 @@ export class UploadController {
71
91
  return { files }
72
92
  }
73
93
 
74
- /** GET /demo/upload/file/:name — 저장된 파일을 소스에서 읽어 전송(인증 필요). @param {any} req @param {any} reply @param {any} ctx */
94
+ /** GET /demo/upload/file/:name?as=표시명 — 저장된 파일을 소스에서 읽어 전송(인증 필요). @param {any} req @param {any} reply @param {any} ctx */
75
95
  static async download(req, reply, ctx) {
76
96
  // basename 으로 디렉터리 성분 제거(경로 탐색 1차 차단) — 'a/../b' / '../etc' 류를 단일 파일명으로.
77
97
  const safe = basename(String(req.params?.name ?? ''))
@@ -89,9 +109,12 @@ export class UploadController {
89
109
  if (err instanceof MegaNotFoundError) throw err
90
110
  throw new MegaNotFoundError('upload.not_found', `File '${safe}' not found.`, { cause: err })
91
111
  }
92
- ctx.log?.debug?.({ file: safe }, 'upload-demo.download')
112
+ // 받는 파일명 — 저장명엔 유일화 접미가 붙어 있으므로 ?as= 의 원본 표시명을 우선한다(없으면 저장명).
113
+ // basename 으로 경로 성분만 제거해 헤더에 쓴다(디스크 접근은 위의 safe 로만).
114
+ const display = basename(String(req.query?.as ?? '')) || safe
115
+ ctx.log?.debug?.({ file: safe, display }, 'upload-demo.download')
93
116
  // 소스에서 스트리밍 전송(정적 서빙 아님). 스트림 payload 라 Fastify 가 envelope 직렬화를 건너뛴다.
94
- reply.header('content-disposition', `attachment; filename="${encodeURIComponent(safe)}"`)
117
+ reply.header('content-disposition', contentDispositionAttachment(display))
95
118
  reply.type(EXT_MIME[extname(safe).toLowerCase()] ?? 'application/octet-stream')
96
119
  return reply.send(createReadStream(abs))
97
120
  }
@@ -6,6 +6,24 @@ const FLAKY_SUCCEED_ON = 2
6
6
  /** 시도 카운터 키 TTL(초) — 데모 잡은 짧게 살고 사라진다. */
7
7
  const ATTEMPT_TTL = 600
8
8
 
9
+ /**
10
+ * EmailJob run 전체(재시도 포함) 실행 상한(ms) — 'hang' 모드 timeout 시연용 backstop. run 이 이 값을 넘게
11
+ * 돌면 MegaJobQueue 가 timeout 으로 판정해 잡을 실패시키고 DLQ 로 보낸다. ok/flaky/fail 의 실 실행 시간
12
+ * (< 3s — fail 의 백오프 최악치 ~3s 포함)보다 충분히 크게 둬(5s) 기존 모드엔 영향이 없게 한다.
13
+ */
14
+ const RUN_TIMEOUT_MS = 5000
15
+ /** 'hang' 모드 기본 지연(ms) — RUN_TIMEOUT_MS 보다 커서 그냥 enqueue 해도 timeout 을 시연한다. */
16
+ const HANG_DEFAULT_DELAY_MS = 8000
17
+
18
+ /**
19
+ * 지정 ms 만큼 멈춘다('hang' 모드 시뮬레이션). 프레임워크는 timeout 시 진행 중 run 을 **중단하지 않으므로**
20
+ * (abort 없음 — run 은 멱등 설계), 이 sleep 은 timeout 후에도 백그라운드에서 끝까지 흐른다.
21
+ * @param {number} ms @returns {Promise<void>}
22
+ */
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, ms))
25
+ }
26
+
9
27
  /**
10
28
  * EmailJob — /demo/jobs 데모 잡(ADR-028/119). `mega worker` 프로세스(ecosystem instances:2)가 소비한다
11
29
  * (config.jobs). 실제 메일은 보내지 않고 발송을 **시뮬레이션**하며, payload.mode 로 세 가지 흐름을 시연한다:
@@ -14,6 +32,10 @@ const ATTEMPT_TTL = 600
14
32
  * - `flaky` : 1번째 시도는 throw(일시 실패) → MegaJobQueue 가 재시도(p-retry, static retries/backoff) →
15
33
  * 2번째 시도에 성공. "일시 오류는 그냥 throw 하면 재시도된다"(MegaJob 정본)를 보여준다.
16
34
  * - `fail` : 매 시도 throw(영구 실패) → 재시도 소진 후 DLQ(`<subject>.dlq`)로 격리된다.
35
+ * - `hang` : payload.delayMs 만큼 잠든다(끝나지 않는 잡 시뮬레이션). delayMs 가 `static timeoutMs`(5s)를
36
+ * 넘으면 MegaJobQueue 가 run 전체에 건 상한을 초과로 판정해 잡을 실패시키고 DLQ 로 보낸다.
37
+ * timeout 후에도 run 은 백그라운드에서 계속 흐르므로(abort 없음) delayMs 뒤 'sent' 가 뒤늦게
38
+ * 남는다 — "run 은 멱등하게 설계하라"는 정본 경고를 눈으로 보여준다.
17
39
  *
18
40
  * 각 시도를 'demo' 캐시(redis)에 기록해(시도 카운터 + 이벤트 LIST) 웹 페이지가 처리 타임라인을 보여준다.
19
41
  * NATS durable consumer group(같은 durable 이름)이라 instances:2 워커가 메시지를 자연 분산 처리한다(중복 X).
@@ -25,6 +47,9 @@ export class EmailJob extends MegaJob {
25
47
  // 추가 재시도 2회(첫 시도 포함 최대 3회). 데모 체감용으로 백오프를 짧게 둔다(기본 1s~30s 대신 0.5s~2s).
26
48
  static retries = 2
27
49
  static backoff = { type: 'exponential', initial: 500, max: 2000 }
50
+ // run 전체(재시도 포함) 상한 — 'hang' 모드 timeout 시연(미지정이면 큐 디폴트 30분이라 체감 불가). ok/flaky/
51
+ // fail 은 < 3s 라 영향 없다(fail 백오프 최악치 ~3s + 마진). 초과 run 은 timeout 실패 → DLQ.
52
+ static timeoutMs = RUN_TIMEOUT_MS
28
53
 
29
54
  /** 이벤트 LIST 키(LPUSH → 최신이 앞). 웹이 LRANGE 로 읽는다. */
30
55
  static EVENTS_KEY = 'demo:jobs:events'
@@ -33,7 +58,7 @@ export class EmailJob extends MegaJob {
33
58
 
34
59
  /**
35
60
  * 메시지 1건 처리. throw 하면 MegaJobQueue 가 재시도/DLQ 를 담당하므로 일시·영구 실패는 그냥 throw 한다.
36
- * @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail' }} payload
61
+ * @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail'|'hang', delayMs?: number }} payload
37
62
  * @param {Record<string, any>} ctx - worker 프로세스 컨텍스트(ctx.cache('demo') 글로벌 키).
38
63
  * @returns {Promise<{ id: string, status: 'sent', attempt: number }>}
39
64
  * @throws {Error} flaky 의 1번째 시도, fail 의 모든 시도 — 재시도/DLQ 트리거.
@@ -46,6 +71,16 @@ export class EmailJob extends MegaJob {
46
71
  await redis.expire(`demo:jobs:attempt:${id}`, ATTEMPT_TTL)
47
72
  ctx.log?.debug?.({ id, mode, attempt }, 'email-job.run')
48
73
 
74
+ if (mode === 'hang') {
75
+ // 오래 끄는 잡 — delayMs 가 static timeoutMs(5s)를 넘으면 큐가 run 상한 초과로 판정해 잡을 실패시키고
76
+ // DLQ 로 보낸다(fail(phase:'run') + dlq, ADR-223 로깅으로 표면화). timeout 후에도 이 run 은 백그라운드
77
+ // 에서 계속 흘러(abort 없음) delayMs 뒤 'sent' 를 뒤늦게 남긴다 — 멱등 설계 경고를 눈으로 보여준다.
78
+ const delayMs = Number.isInteger(payload.delayMs) ? /** @type {number} */ (payload.delayMs) : HANG_DEFAULT_DELAY_MS
79
+ await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'running' })
80
+ await sleep(delayMs)
81
+ await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'sent' })
82
+ return { id, status: 'sent', attempt }
83
+ }
49
84
  if (mode === 'flaky' && attempt < FLAKY_SUCCEED_ON) {
50
85
  await EmailJob.#logEvent(redis, { id, to, mode, attempt, status: 'retry' })
51
86
  throw new Error(`EmailJob ${id}: simulated transient failure on attempt ${attempt} (will retry)`)
@@ -62,7 +97,7 @@ export class EmailJob extends MegaJob {
62
97
  /**
63
98
  * 처리 이벤트 1건을 redis LIST 머리에 넣고 최근 N건만 남긴다.
64
99
  * @param {any} redis - ioredis 핸들(ctx.cache('demo').native).
65
- * @param {{ id: string, to: string, mode: string, attempt: number, status: 'retry'|'failed'|'sent' }} event
100
+ * @param {{ id: string, to: string, mode: string, attempt: number, status: 'retry'|'failed'|'sent'|'running' }} event
66
101
  * @returns {Promise<void>}
67
102
  */
68
103
  static async #logEvent(redis, event) {
@@ -251,12 +251,16 @@
251
251
  "jobs_reload": "Reload",
252
252
  "jobs_field_to": "Recipient",
253
253
  "jobs_field_mode": "Mode",
254
+ "jobs_field_delay": "Delay (ms) — hang mode only",
255
+ "jobs_field_delay_hint": "Leave blank for the default 8000ms. Exceeding 5000ms (timeoutMs) isolates the job to the DLQ on timeout.",
254
256
  "jobs_mode_ok": "Success",
255
257
  "jobs_mode_flaky": "Retry",
256
258
  "jobs_mode_fail": "Permanent failure",
259
+ "jobs_mode_hang": "Timeout",
257
260
  "jobs_mode_ok_hint": "succeeds on the first attempt",
258
261
  "jobs_mode_flaky_hint": "1st attempt fails → retry → 2nd succeeds",
259
262
  "jobs_mode_fail_hint": "every attempt fails → DLQ after retries are exhausted",
263
+ "jobs_mode_hang_hint": "hangs for delayMs → exceeds 5s (timeoutMs) → timeout → DLQ",
260
264
  "jobs_dlq_title": "DLQ (isolated jobs)",
261
265
  "jobs_dlq_desc": "Where permanently failed jobs land after retries are exhausted (a NATS stream). For analysis and reprocessing.",
262
266
  "jobs_dlq_empty": "No jobs have reached the DLQ yet.",
@@ -274,6 +278,7 @@
274
278
  "jobs_status_sent": "sent",
275
279
  "jobs_status_retry": "retry",
276
280
  "jobs_status_failed": "failed",
281
+ "jobs_status_running": "running",
277
282
  "jobs_notice_enqueued": "Job enqueued. Once a worker processes it, it appears in the events below.",
278
283
  "worker_title": "CPU worker demo (MegaWorker)",
279
284
  "worker_subtitle": "Runs CPU-bound work like N rounds of SHA-256 in a worker_threads pool. A heartbeat confirms the server still answers other requests instantly while computing (main thread non-blocking).",
@@ -251,12 +251,16 @@
251
251
  "jobs_reload": "새로고침",
252
252
  "jobs_field_to": "받는 사람",
253
253
  "jobs_field_mode": "모드",
254
+ "jobs_field_delay": "지연(ms) — hang 모드 전용",
255
+ "jobs_field_delay_hint": "비워두면 기본 8000ms. 5000ms(timeoutMs) 초과 시 timeout 으로 DLQ 격리됩니다.",
254
256
  "jobs_mode_ok": "성공",
255
257
  "jobs_mode_flaky": "재시도",
256
258
  "jobs_mode_fail": "영구 실패",
259
+ "jobs_mode_hang": "타임아웃",
257
260
  "jobs_mode_ok_hint": "첫 시도에 바로 성공",
258
261
  "jobs_mode_flaky_hint": "1번째 시도 실패 → 재시도 → 2번째 성공",
259
262
  "jobs_mode_fail_hint": "매 시도 실패 → 재시도 소진 후 DLQ 격리",
263
+ "jobs_mode_hang_hint": "delayMs 만큼 지연 → 5초(timeoutMs) 초과 시 timeout → DLQ",
260
264
  "jobs_dlq_title": "DLQ (격리된 잡)",
261
265
  "jobs_dlq_desc": "재시도를 모두 소진한 영구 실패 잡이 모이는 곳입니다(NATS 스트림). 원인 분석·재처리용.",
262
266
  "jobs_dlq_empty": "아직 DLQ 로 간 잡이 없습니다.",
@@ -274,6 +278,7 @@
274
278
  "jobs_status_sent": "발송됨",
275
279
  "jobs_status_retry": "재시도",
276
280
  "jobs_status_failed": "실패",
281
+ "jobs_status_running": "처리 중",
277
282
  "jobs_notice_enqueued": "잡을 큐에 넣었습니다. 워커가 처리하면 아래 이벤트에 나타납니다.",
278
283
  "worker_title": "CPU 워커 데모 (MegaWorker)",
279
284
  "worker_subtitle": "SHA-256 N회 반복 같은 CPU-bound 작업을 worker_threads 풀에서 돌립니다. 계산 도중에도 서버가 다른 요청에 즉시 응답하는지(메인 스레드 non-block) 하트비트로 확인합니다.",
@@ -6,11 +6,30 @@
6
6
  import { UploadController } from '../controllers/upload-controller.js'
7
7
  import { webRequireAuth } from '../middleware/web-auth.js'
8
8
 
9
+ /**
10
+ * 다운로드 path 파라미터 스키마(ADR-019) — `:name` 은 실제 저장 파일명(유일화 접미 포함, 한글 가능)이라
11
+ * 형식(빈 값·과대 길이)만 1차 검증한다. 경로 탐색 차단은 컨트롤러의 basename + 디렉터리 내부 검증이 맡는다.
12
+ */
13
+ const downloadParams = {
14
+ type: 'object',
15
+ required: ['name'],
16
+ properties: { name: { type: 'string', minLength: 1, maxLength: 255 } },
17
+ }
18
+
19
+ /** 다운로드 query 스키마 — `as` 는 받는 쪽에 제안할 표시 파일명(Content-Disposition 용, 선택). */
20
+ const downloadQuery = {
21
+ type: 'object',
22
+ properties: { as: { type: 'string', maxLength: 255 } },
23
+ }
24
+
9
25
  export default (/** @type {any} */ router) => {
10
26
  /** @type {{ before: Function[] }} 데모 UI 보호 가드(로그인 필요). */
11
27
  const guarded = { before: [webRequireAuth] }
12
28
  router.http.get('/demo/upload', UploadController.index, guarded)
13
29
  router.http.post('/demo/upload', UploadController.upload, guarded)
14
30
  // 다운로드 — 소스에서 파일 읽어 전송(인증 필요). 가드를 떼면 공개 다운로드로 전환된다.
15
- router.http.get('/demo/upload/file/:name', UploadController.download, guarded)
31
+ router.http.get('/demo/upload/file/:name', UploadController.download, {
32
+ ...guarded,
33
+ schema: { params: downloadParams, querystring: downloadQuery },
34
+ })
16
35
  }
@@ -9,7 +9,7 @@ import { MegaNotFoundError } from 'mega-framework/errors'
9
9
 
10
10
  /**
11
11
  * GuideService — /guide 가이드 뷰어 로직(서버사이드 마크다운 렌더). 파일명 `guide-service.js` → 자동 DI
12
- * 이름 `guide`(ctx.services.guide, ADR-148). 레포 루트의 `docs/guide/*.md` 를 읽어,
12
+ * 이름 `guide`(ctx.services.guide, ADR-148). 프로젝트의 `docs/guide/*.md` 를 읽어,
13
13
  *
14
14
  * 1) **목록** — 디렉토리를 scan 해 각 파일의 첫 H1 을 제목으로 뽑는다(인덱스 카드용).
15
15
  * 2) **단일 페이지** — 마크다운을 marked 로 HTML 화하고, 코드블록은 highlight.js 로 미리 하이라이트한다.
@@ -18,8 +18,9 @@ import { MegaNotFoundError } from 'mega-framework/errors'
18
18
  * 가이드 파일은 정적이라 목록은 프로세스 단위로 캐시한다(개발 중 변경은 재시작으로 갱신).
19
19
  */
20
20
 
21
- /** 가이드 마크다운 디렉토리 — 레포 루트의 docs/guide(이 파일 기준 5단계 위). */
22
- const GUIDE_DIR = fileURLToPath(new URL('../../../../../docs/guide/', import.meta.url))
21
+ /** 가이드 마크다운 디렉토리 — 프로젝트의 docs/guide(이 파일 기준 3단계 위 = 프로젝트 루트). `mega new` 가
22
+ * sample/crud 트리를 그대로 복사하므로(ADR-179) 스캐폴드된 프로젝트에서도 같은 상대 위치에 존재한다. */
23
+ const GUIDE_DIR = fileURLToPath(new URL('../../../docs/guide/', import.meta.url))
23
24
 
24
25
  /** 유효 slug 형식 — 소문자·숫자·하이픈만(경로 조작 차단의 1차 게이트). */
25
26
  const SLUG_RE = /^[a-z0-9-]+$/
@@ -25,7 +25,7 @@ const DLQ_STREAM = `MEGA_JOBS_${EmailJob.subject.replace(/[^A-Za-z0-9_-]/g, '_')
25
25
  export class JobsDemoService extends MegaService {
26
26
  /**
27
27
  * EmailJob 1건을 큐에 넣는다(JetStream publish — 서버에 영속 저장 후 워커가 가져감).
28
- * @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail' }} payload
28
+ * @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail'|'hang', delayMs?: number }} payload
29
29
  * @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
30
30
  */
31
31
  async enqueue(payload) {
@@ -17,8 +17,9 @@ export class UploadDemoService extends MegaService {
17
17
 
18
18
  /**
19
19
  * 저장 결과 메타를 최근 이력에 기록한다(파일별 1건). 저장 경로(path)는 프로젝트 루트 기준 상대경로다
20
- * (서버 절대경로 비노출 — 데모에선 위치 확인이 목적).
21
- * @param {Array<{ filename: string, bytes: number, mimetype: string, path: string }>} saved - 저장 결과 메타.
20
+ * (서버 절대경로 비노출 — 데모에선 위치 확인이 목적). savedName 은 디스크의 실제 저장 파일명(유일화 접미
21
+ * 포함) 다운로드 URL 키로 쓴다(표시명 filename 다름, ADR-187).
22
+ * @param {Array<{ filename: string, savedName?: string, bytes: number, mimetype: string, path: string }>} saved - 저장 결과 메타.
22
23
  * @returns {Promise<void>}
23
24
  */
24
25
  async record(saved) {
@@ -28,7 +29,7 @@ export class UploadDemoService extends MegaService {
28
29
  for (const f of saved) {
29
30
  await redis.lpush(
30
31
  UploadDemoService.RECENT_KEY,
31
- JSON.stringify({ filename: f.filename, bytes: f.bytes, mimetype: f.mimetype, path: f.path ?? null, at }),
32
+ JSON.stringify({ filename: f.filename, savedName: f.savedName ?? null, bytes: f.bytes, mimetype: f.mimetype, path: f.path ?? null, at }),
32
33
  )
33
34
  }
34
35
  await redis.ltrim(UploadDemoService.RECENT_KEY, 0, UploadDemoService.RECENT_MAX - 1)
@@ -36,8 +37,8 @@ export class UploadDemoService extends MegaService {
36
37
  }
37
38
 
38
39
  /**
39
- * 화면 렌더용 스냅샷 — 최근 업로드 메타 목록.
40
- * @returns {Promise<{ recent: Array<{ filename: string, bytes: number, mimetype: string, path: string|null, at: string }> }>}
40
+ * 화면 렌더용 스냅샷 — 최근 업로드 메타 목록. savedName 도입 전 이력엔 필드가 없을 수 있다(뷰가 path 로 보완).
41
+ * @returns {Promise<{ recent: Array<{ filename: string, savedName?: string|null, bytes: number, mimetype: string, path: string|null, at: string }> }>}
41
42
  */
42
43
  async snapshot() {
43
44
  const redis = this.ctx.cache('demo').native
@@ -1,8 +1,8 @@
1
1
  <% layout('layouts/main') %>
2
2
  <%
3
3
  function fmt(d) { return new Date(d).toLocaleString('ko-KR', { hour12: false }) }
4
- function statusBadge(s) { return s === 'sent' ? 'text-bg-success' : (s === 'retry' ? 'text-bg-warning' : 'text-bg-danger') }
5
- function modeBadge(m) { return m === 'ok' ? 'text-bg-success' : (m === 'flaky' ? 'text-bg-warning' : 'text-bg-danger') }
4
+ function statusBadge(s) { return s === 'sent' ? 'text-bg-success' : (s === 'retry' ? 'text-bg-warning' : (s === 'running' ? 'text-bg-info' : 'text-bg-danger')) }
5
+ function modeBadge(m) { return m === 'ok' ? 'text-bg-success' : (m === 'flaky' ? 'text-bg-warning' : (m === 'hang' ? 'text-bg-info' : 'text-bg-danger')) }
6
6
  %>
7
7
 
8
8
  <div class="mb-4">
@@ -39,9 +39,15 @@
39
39
  <div class="form-text small">
40
40
  <%= t('jobs_mode_ok', { defaultValue: '성공' }) %>: <%= t('jobs_mode_ok_hint', { defaultValue: '첫 시도에 바로 성공' }) %><br />
41
41
  <%= t('jobs_mode_flaky', { defaultValue: '재시도' }) %>: <%= t('jobs_mode_flaky_hint', { defaultValue: '1번째 시도 실패 → 재시도 → 2번째 성공' }) %><br />
42
- <%= t('jobs_mode_fail', { defaultValue: '영구 실패' }) %>: <%= t('jobs_mode_fail_hint', { defaultValue: '매 시도 실패 → 재시도 소진 후 DLQ 격리' }) %>
42
+ <%= t('jobs_mode_fail', { defaultValue: '영구 실패' }) %>: <%= t('jobs_mode_fail_hint', { defaultValue: '매 시도 실패 → 재시도 소진 후 DLQ 격리' }) %><br />
43
+ <%= t('jobs_mode_hang', { defaultValue: '타임아웃' }) %>: <%= t('jobs_mode_hang_hint', { defaultValue: 'delayMs 만큼 지연 → 5초(timeoutMs) 초과 시 timeout → DLQ' }) %>
43
44
  </div>
44
45
  </div>
46
+ <div class="mb-3">
47
+ <label for="delayMs" class="form-label small"><%= t('jobs_field_delay', { defaultValue: '지연(ms) — hang 모드 전용' }) %></label>
48
+ <input type="number" class="form-control form-control-sm" id="delayMs" name="delayMs" min="0" max="600000" step="500" placeholder="8000" />
49
+ <div class="form-text small"><%= t('jobs_field_delay_hint', { defaultValue: '비워두면 기본 8000ms. 5000ms(timeoutMs) 초과 시 timeout 으로 DLQ 격리됩니다.' }) %></div>
50
+ </div>
45
51
  <button type="submit" class="btn btn-primary btn-sm"><%= t('jobs_enqueue_btn', { defaultValue: '큐에 넣기' }) %></button>
46
52
  <a href="/demo/jobs" class="btn btn-outline-secondary btn-sm ms-1"><%= t('jobs_reload', { defaultValue: '새로고침' }) %></a>
47
53
  </form>
@@ -2,6 +2,9 @@
2
2
  <%
3
3
  function fmt(d) { return new Date(d).toLocaleString('ko-KR', { hour12: false, timeZone: 'Asia/Seoul' }) }
4
4
  function kb(b) { return (Number(b) / 1024).toFixed(1) + ' KB' }
5
+ // 다운로드 키 — 디스크의 실제 저장 파일명(유일화 접미 포함). savedName 도입 전 이력은 저장 경로의
6
+ // basename 으로 보완한다(표시명으론 디스크 파일을 못 찾음).
7
+ function dlName(e) { return e.savedName || String(e.path || e.filename).split(/[\\/]/).pop() }
5
8
  %>
6
9
 
7
10
  <div class="mb-4">
@@ -60,7 +63,7 @@
60
63
  <tr>
61
64
  <td class="font-monospace small"><%= fmt(e.at) %></td>
62
65
  <td class="small text-break">
63
- <a href="/demo/upload/file/<%= encodeURIComponent(e.filename) %>"><%= e.filename %></a>
66
+ <a href="/demo/upload/file/<%= encodeURIComponent(dlName(e)) %>?as=<%= encodeURIComponent(e.filename) %>"><%= e.filename %></a>
64
67
  </td>
65
68
  <td class="font-monospace small text-break"><%= e.path || '—' %></td>
66
69
  <td><span class="badge text-bg-light"><code><%= e.mimetype %></code></span></td>