mega-framework 0.1.7 → 0.1.9

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 (95) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -3
  3. package/sample/crud/.env +9 -0
  4. package/sample/crud/.env.example +9 -0
  5. package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
  6. package/sample/crud/apps/main/locales/server/en.json +12 -1
  7. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  8. package/sample/crud/apps/main/routes/upload.js +20 -1
  9. package/sample/crud/apps/main/services/guide-service.js +4 -3
  10. package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
  11. package/sample/crud/apps/main/views/upload/index.ejs +4 -1
  12. package/sample/crud/docs/guide/01-cli.md +587 -0
  13. package/sample/crud/docs/guide/02-router-controller.md +497 -0
  14. package/sample/crud/docs/guide/03-service-model-db.md +929 -0
  15. package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
  16. package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
  17. package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
  18. package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
  19. package/sample/crud/docs/guide/08-observability.md +373 -0
  20. package/sample/crud/mega.config.js +7 -0
  21. package/sample/crud/package.json +2 -2
  22. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  23. 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
  24. 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
  25. package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
  26. package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
  27. package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
  28. 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
  29. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  30. package/src/adapters/adapter-options.js +14 -3
  31. package/src/adapters/file-adapter.js +9 -5
  32. package/src/adapters/file-session-adapter.js +4 -3
  33. package/src/adapters/maria-adapter.js +7 -4
  34. package/src/adapters/mega-cache-adapter.js +83 -6
  35. package/src/adapters/mega-db-adapter.js +4 -1
  36. package/src/adapters/mongo-adapter.js +21 -7
  37. package/src/adapters/postgres-adapter.js +8 -4
  38. package/src/adapters/redis-adapter.js +7 -3
  39. package/src/adapters/sqlite-adapter.js +6 -2
  40. package/src/cli/commands/console-cmd.js +3 -1
  41. package/src/cli/commands/scaffold.js +38 -2
  42. package/src/cli/generators/index.js +58 -1
  43. package/src/cli/index.js +88 -59
  44. package/src/cli/watch.js +188 -0
  45. package/src/core/ajv-mapper.js +3 -1
  46. package/src/core/ctx-builder.js +59 -1
  47. package/src/core/envelope.js +9 -2
  48. package/src/core/hub-link.js +24 -14
  49. package/src/core/index.js +1 -1
  50. package/src/core/mega-app.js +55 -45
  51. package/src/core/pipeline.js +8 -6
  52. package/src/core/scope-registry.js +1 -0
  53. package/src/core/security.js +3 -3
  54. package/src/core/session-store.js +14 -1
  55. package/src/core/ws-presence.js +17 -5
  56. package/src/core/ws-roster.js +49 -10
  57. package/src/core/ws-upgrade.js +105 -0
  58. package/src/lib/mega-circuit-breaker.js +5 -3
  59. package/src/lib/mega-health.js +10 -0
  60. package/src/lib/mega-job-queue.js +53 -13
  61. package/src/lib/mega-job.js +8 -1
  62. package/src/lib/mega-metrics.js +28 -1
  63. package/src/lib/mega-plugin.js +2 -2
  64. package/src/lib/mega-worker.js +28 -5
  65. package/src/lib/ws-hub.js +90 -9
  66. package/templates/adr/code.tpl +23 -0
  67. package/types/adapters/adapter-options.d.ts +2 -0
  68. package/types/adapters/file-adapter.d.ts +12 -1
  69. package/types/adapters/file-session-adapter.d.ts +4 -2
  70. package/types/adapters/maria-adapter.d.ts +5 -3
  71. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  72. package/types/adapters/mega-db-adapter.d.ts +4 -1
  73. package/types/adapters/mongo-adapter.d.ts +13 -2
  74. package/types/adapters/postgres-adapter.d.ts +4 -2
  75. package/types/adapters/redis-adapter.d.ts +8 -0
  76. package/types/adapters/sqlite-adapter.d.ts +8 -2
  77. package/types/cli/generators/index.d.ts +11 -1
  78. package/types/cli/index.d.ts +12 -27
  79. package/types/cli/watch.d.ts +59 -0
  80. package/types/core/ctx-builder.d.ts +23 -0
  81. package/types/core/hub-link.d.ts +3 -1
  82. package/types/core/index.d.ts +1 -1
  83. package/types/core/mega-app.d.ts +1 -1
  84. package/types/core/pipeline.d.ts +2 -1
  85. package/types/core/security.d.ts +3 -3
  86. package/types/core/session-store.d.ts +7 -0
  87. package/types/core/ws-roster.d.ts +13 -1
  88. package/types/core/ws-upgrade.d.ts +29 -0
  89. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  90. package/types/lib/mega-health.d.ts +7 -0
  91. package/types/lib/mega-job-queue.d.ts +16 -4
  92. package/types/lib/mega-job.d.ts +8 -1
  93. package/types/lib/mega-plugin.d.ts +1 -1
  94. package/types/lib/mega-worker.d.ts +3 -1
  95. package/types/lib/ws-hub.d.ts +27 -2
package/README.md CHANGED
@@ -39,6 +39,15 @@ npm test # 전체 테스트(실 인프라). .env 자동 로드(ADR-
39
39
  npm run infra:down # 인프라 종료
40
40
  ```
41
41
 
42
+ > **워크스페이스 안내 (ADR-197)** — `sample/crud`·`sample/simple` 은 npm workspaces 라서 모든 의존성이
43
+ > **루트 `node_modules` 로 hoist** 된다. `sample/crud/node_modules` 가 안 생기는 것이 **정상**이며
44
+ > (sample 안에서 `npm install` 을 실행해도 루트에 설치된다), `mega-framework` 는
45
+ > `node_modules/mega-framework → ..` self-link 로 해석된다. 확인: `npm ls --workspace sample-crud`.
46
+ >
47
+ > **yarn 미지원** — yarn v1 은 workspaces 에 루트 `"private": true` 를 요구하는데 본 패키지는 npm 배포
48
+ > 대상(`private: false`)이라 `yarn install` 이 `Workspaces can only be enabled in private projects` 로
49
+ > 실패한다. **npm 만 사용**한다(lockfile 정본 = `package-lock.json` 1벌).
50
+
42
51
  프로젝트 루트에 `mega.config.js`(글로벌) + 앱별 `apps/<name>/app.config.js` 를 둔다.
43
52
 
44
53
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -69,7 +69,7 @@
69
69
  "test": "vitest run",
70
70
  "test:watch": "vitest",
71
71
  "test:coverage": "vitest run --coverage",
72
- "lint": "eslint src test",
72
+ "lint": "eslint src test scripts",
73
73
  "typecheck": "tsc -p jsconfig.json --noEmit",
74
74
  "build:types": "rm -rf types && tsc -p tsconfig.build.json",
75
75
  "format": "prettier -w src test packages",
@@ -79,8 +79,8 @@
79
79
  "infra:reset": "docker compose down -v && docker compose up -d --wait",
80
80
  "test:integration": "npm run infra:up && vitest run test/integration && npm run infra:down",
81
81
  "test:unit": "vitest run test/unit",
82
+ "adr:index": "node scripts/build-adr-index.js",
82
83
  "prepublishOnly": "npm run build:types && npm run lint && npm run typecheck && npm run test:unit",
83
- "prepare": "npm run build:types",
84
84
  "release": "bash scripts/publish.sh"
85
85
  },
86
86
  "devDependencies": {
package/sample/crud/.env CHANGED
@@ -20,6 +20,15 @@
20
20
  # NODE_ENV=development
21
21
 
22
22
 
23
+ # ── dev watch ignore (mega.config.js > watch, ADR-220) ───────────────────────
24
+ # `mega start --watch` 의 ignore 목록(콤마 구분 glob) — **여기가 정본**(숨은 코드 디폴트 없음).
25
+ # "런타임이 스스로 쓰거나 재시작 없이 반영되는" 영역을 나열한다. 폴더명을 바꿨으면 이 줄만 고친다.
26
+ # locales: dev 의 i18n saveMissing 자동 기입(안 빼면 재시작 무한 루프) / views: EJS 는 dev 에서
27
+ # 매 요청 재컴파일 / public: 정적 자산 / uploads·var: 사용자 업로드 등 런타임 데이터 /
28
+ # .mega: 마이그레이션 journal / .env*: 시크릿 변경은 수동 재시작.
29
+ # 비우면 모든 변경이 재시작 대상이다(기동 시 경고 1줄).
30
+ WATCH_IGNORE=**/locales/**,**/views/**,**/public/**,**/uploads/**,var/**,logs/**,tmp/**,.mega/**,**/node_modules/**,**/.git/**,**/*.test.js,**/*.spec.js,.env,.env.*,**/templates/**,data/**
31
+
23
32
  # ── 서버 (mega.config.js > server) ───────────────────────────────────────────
24
33
  # HTTP listen 포트 — server.port. CLI `--port N` 이 우선(ADR-146).
25
34
  PORT=3000
@@ -20,6 +20,15 @@
20
20
  # NODE_ENV=development
21
21
 
22
22
 
23
+ # ── dev watch ignore (mega.config.js > watch, ADR-220) ───────────────────────
24
+ # `mega start --watch` 의 ignore 목록(콤마 구분 glob) — **여기가 정본**(숨은 코드 디폴트 없음).
25
+ # "런타임이 스스로 쓰거나 재시작 없이 반영되는" 영역을 나열한다. 폴더명을 바꿨으면 이 줄만 고친다.
26
+ # locales: dev 의 i18n saveMissing 자동 기입(안 빼면 재시작 무한 루프) / views: EJS 는 dev 에서
27
+ # 매 요청 재컴파일 / public: 정적 자산 / uploads·var: 사용자 업로드 등 런타임 데이터 /
28
+ # .mega: 마이그레이션 journal / .env*: 시크릿 변경은 수동 재시작.
29
+ # 비우면 모든 변경이 재시작 대상이다(기동 시 경고 1줄).
30
+ WATCH_IGNORE=**/locales/**,**/views/**,**/public/**,**/uploads/**,var/**,logs/**,tmp/**,.mega/**,**/node_modules/**,**/.git/**,**/*.test.js,**/*.spec.js,.env,.env.*
31
+
23
32
  # ── 서버 (mega.config.js > server) ───────────────────────────────────────────
24
33
  # HTTP listen 포트 — server.port. CLI `--port N` 이 우선(ADR-146).
25
34
  PORT=3000
@@ -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
  }
@@ -313,5 +313,16 @@
313
313
  "csrf": {
314
314
  "invalid_token": "Invalid csrf token",
315
315
  "missing_secret": "Missing csrf secret"
316
- }
316
+ },
317
+ "field_username": "아이디",
318
+ "field_username_ph": "예: hong123",
319
+ "field_username_required": "아이디를 입력하세요.",
320
+ "field_nickname": "별명",
321
+ "field_nickname_ph": "예: 길동이",
322
+ "field_nickname_required": "별명을 입력하세요.",
323
+ "optional": "선택",
324
+ "field_phone": "전화번호",
325
+ "field_phone_ph": "예: 010-1234-5678",
326
+ "col_username": "아이디",
327
+ "col_nickname": "별명"
317
328
  }
@@ -313,5 +313,16 @@
313
313
  },
314
314
  "guide": {
315
315
  "not_found": "가이드를 찾을 수 없습니다."
316
- }
316
+ },
317
+ "field_username": "아이디",
318
+ "field_username_ph": "예: hong123",
319
+ "field_username_required": "아이디를 입력하세요.",
320
+ "field_nickname": "별명",
321
+ "field_nickname_ph": "예: 길동이",
322
+ "field_nickname_required": "별명을 입력하세요.",
323
+ "optional": "선택",
324
+ "field_phone": "전화번호",
325
+ "field_phone_ph": "예: 010-1234-5678",
326
+ "col_username": "아이디",
327
+ "col_nickname": "별명"
317
328
  }
@@ -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-]+$/
@@ -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
@@ -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>