mega-framework 0.1.5 → 0.1.7
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.
- package/bin/mega-ws-hub.js +2 -2
- package/package.json +32 -8
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
- package/sample/crud/.mega/journal/snapshot.json +261 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
- package/sample/crud/apps/main/controllers/web-controller.js +7 -5
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
- package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
- package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
- package/sample/crud/apps/main/models/log-partition-model.js +105 -0
- package/sample/crud/apps/main/models/note-model.js +79 -0
- package/sample/crud/apps/main/models/user-level-model.js +24 -0
- package/sample/crud/apps/main/models/user-model.js +146 -0
- package/sample/crud/apps/main/models/user-type-model.js +21 -0
- package/sample/crud/apps/main/models/wallet-model.js +24 -0
- package/sample/crud/apps/main/routes/users.js +55 -10
- package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
- package/sample/crud/apps/main/services/auth-service.js +39 -24
- package/sample/crud/apps/main/services/log-partition-service.js +101 -0
- package/sample/crud/apps/main/services/note-service.js +6 -6
- package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
- package/sample/crud/apps/main/services/user-service.js +62 -21
- package/sample/crud/apps/main/views/auth/login.ejs +6 -6
- package/sample/crud/apps/main/views/auth/register.ejs +46 -5
- package/sample/crud/apps/main/views/users/edit.ejs +42 -5
- package/sample/crud/apps/main/views/users/list.ejs +6 -2
- package/sample/crud/apps/main/views/users/new.ejs +56 -4
- package/sample/crud/docs/log_partition_design.mm.md +23 -0
- package/sample/crud/mega.config.js +63 -3
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +2 -2
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +30 -0
- package/src/adapters/maria-adapter.js +26 -3
- package/src/adapters/mega-db-adapter.js +7 -1
- package/src/adapters/mongo-adapter.js +19 -1
- package/src/adapters/postgres-adapter.js +25 -2
- package/src/adapters/sqlite-adapter.js +20 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +137 -33
- package/src/cli/generators/index.js +82 -2
- package/src/cli/index.js +478 -104
- package/src/core/ajv-mapper.js +27 -2
- package/src/core/boot.js +485 -237
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/config-validator.js +25 -0
- package/src/core/ctx-builder.js +6 -2
- package/src/core/envelope.js +112 -12
- package/src/core/hub-link.js +65 -4
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +6 -2
- package/src/core/mega-app.js +223 -481
- package/src/core/mega-cluster.js +54 -13
- package/src/core/mega-server.js +40 -9
- package/src/core/migration/dialect-registry.js +107 -0
- package/src/core/migration/dialects/README.md +62 -0
- package/src/core/migration/dialects/maria.js +496 -0
- package/src/core/migration/dialects/mongo.js +824 -0
- package/src/core/migration/dialects/postgres.js +563 -0
- package/src/core/migration/dialects/sqlite.js +476 -0
- package/src/core/migration/differ.js +456 -0
- package/src/core/migration/generate.js +508 -0
- package/src/core/migration/journal.js +167 -0
- package/src/core/migration/model-scan.js +84 -0
- package/src/core/migration/mongo-migration-db.js +97 -0
- package/src/core/migration/schema-builder.js +400 -0
- package/src/core/migration/schema-validator.js +315 -0
- package/src/core/migration-lock.js +205 -0
- package/src/core/migration-runner.js +166 -38
- package/src/core/multipart.js +28 -5
- package/src/core/pipeline.js +129 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +0 -1
- package/src/core/security.js +67 -9
- package/src/core/workers-manager.js +12 -1
- package/src/core/ws-cluster.js +10 -3
- package/src/core/ws-message.js +48 -4
- package/src/core/ws-presence.js +624 -0
- package/src/core/ws-roster.js +4 -1
- package/src/core/ws-upgrade.js +118 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-health.js +25 -4
- package/src/lib/mega-job-queue.js +98 -21
- package/src/lib/mega-job.js +29 -0
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-metrics.js +3 -12
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +162 -49
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +5 -1
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +51 -8
- package/src/models/crud-sql-builder.js +133 -0
- package/src/models/mega-model.js +82 -2
- package/src/models/model-crud.js +483 -0
- package/src/models/mongo-crud.js +285 -0
- package/templates/model/code-mongo.tpl +35 -0
- package/templates/model/code.tpl +15 -1
- package/templates/model/test-mongo.tpl +38 -0
- package/templates/model/test.tpl +4 -0
- package/types/adapters/adapter-manager.d.ts +95 -0
- package/types/adapters/adapter-options.d.ts +91 -0
- package/types/adapters/file-adapter.d.ts +94 -0
- package/types/adapters/file-session-adapter.d.ts +101 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +115 -0
- package/types/adapters/mega-adapter.d.ts +215 -0
- package/types/adapters/mega-bus-adapter.d.ts +45 -0
- package/types/adapters/mega-cache-adapter.d.ts +47 -0
- package/types/adapters/mega-db-adapter.d.ts +47 -0
- package/types/adapters/mega-lock-adapter.d.ts +62 -0
- package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
- package/types/adapters/mega-session-adapter.d.ts +32 -0
- package/types/adapters/mongo-adapter.d.ts +139 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +139 -0
- package/types/adapters/redis-adapter.d.ts +70 -0
- package/types/adapters/redis-session-adapter.d.ts +82 -0
- package/types/adapters/redlock-adapter.d.ts +149 -0
- package/types/adapters/registry.d.ts +46 -0
- package/types/adapters/sqlite-adapter.d.ts +106 -0
- package/types/auth/index.d.ts +24 -0
- package/types/cli/commands/console-cmd.d.ts +37 -0
- package/types/cli/commands/new.d.ts +16 -0
- package/types/cli/commands/routes.d.ts +36 -0
- package/types/cli/commands/scaffold.d.ts +78 -0
- package/types/cli/commands/test-cmd.d.ts +14 -0
- package/types/cli/generators/index.d.ts +112 -0
- package/types/cli/index.d.ts +249 -0
- package/types/cli/template-engine.d.ts +40 -0
- package/types/core/ajv-mapper.d.ts +27 -0
- package/types/core/boot.d.ts +233 -0
- package/types/core/cluster-metrics.d.ts +52 -0
- package/types/core/config-loader.d.ts +13 -0
- package/types/core/config-validator.d.ts +30 -0
- package/types/core/ctx-builder.d.ts +80 -0
- package/types/core/envelope.d.ts +79 -0
- package/types/core/error-mapper.d.ts +17 -0
- package/types/core/formbody.d.ts +41 -0
- package/types/core/hub-link.d.ts +264 -0
- package/types/core/i18n.d.ts +178 -0
- package/types/core/index.d.ts +28 -0
- package/types/core/mega-app.d.ts +529 -0
- package/types/core/mega-cluster.d.ts +104 -0
- package/types/core/mega-server.d.ts +91 -0
- package/types/core/mega-service.d.ts +31 -0
- package/types/core/migration/dialect-registry.d.ts +22 -0
- package/types/core/migration/dialects/maria.d.ts +99 -0
- package/types/core/migration/dialects/mongo.d.ts +89 -0
- package/types/core/migration/dialects/postgres.d.ts +117 -0
- package/types/core/migration/dialects/sqlite.d.ts +111 -0
- package/types/core/migration/differ.d.ts +47 -0
- package/types/core/migration/generate.d.ts +56 -0
- package/types/core/migration/journal.d.ts +52 -0
- package/types/core/migration/model-scan.d.ts +19 -0
- package/types/core/migration/mongo-migration-db.d.ts +7 -0
- package/types/core/migration/schema-builder.d.ts +197 -0
- package/types/core/migration/schema-validator.d.ts +20 -0
- package/types/core/migration-lock.d.ts +33 -0
- package/types/core/migration-runner.d.ts +101 -0
- package/types/core/multipart.d.ts +86 -0
- package/types/core/openapi.d.ts +62 -0
- package/types/core/pipeline.d.ts +92 -0
- package/types/core/router.d.ts +159 -0
- package/types/core/routes-loader.d.ts +21 -0
- package/types/core/scope-registry.d.ts +14 -0
- package/types/core/security.d.ts +77 -0
- package/types/core/services-loader.d.ts +27 -0
- package/types/core/session-cleanup-schedule.d.ts +19 -0
- package/types/core/session-store.d.ts +18 -0
- package/types/core/session.d.ts +77 -0
- package/types/core/static-assets.d.ts +73 -0
- package/types/core/template.d.ts +106 -0
- package/types/core/workers-manager.d.ts +79 -0
- package/types/core/ws-cluster.d.ts +208 -0
- package/types/core/ws-compression.d.ts +112 -0
- package/types/core/ws-controller.d.ts +65 -0
- package/types/core/ws-message.d.ts +106 -0
- package/types/core/ws-presence.d.ts +273 -0
- package/types/core/ws-roster.d.ts +96 -0
- package/types/core/ws-upgrade.d.ts +231 -0
- package/types/errors/config-error.d.ts +10 -0
- package/types/errors/http-errors.d.ts +120 -0
- package/types/errors/index.d.ts +3 -0
- package/types/errors/mega-error.d.ts +32 -0
- package/types/index.d.ts +39 -0
- package/types/lib/asp/config.d.ts +49 -0
- package/types/lib/asp/crypto.d.ts +43 -0
- package/types/lib/asp/errors.d.ts +30 -0
- package/types/lib/asp/nonce-cache.d.ts +52 -0
- package/types/lib/asp/plugin.d.ts +30 -0
- package/types/lib/asp/ws-terminator.d.ts +45 -0
- package/types/lib/env-mapper.d.ts +14 -0
- package/types/lib/hub-protocol.d.ts +106 -0
- package/types/lib/index.d.ts +22 -0
- package/types/lib/logger/telegram-core.d.ts +104 -0
- package/types/lib/logger/telegram-transport.d.ts +45 -0
- package/types/lib/mega-brute-force.d.ts +66 -0
- package/types/lib/mega-circuit-breaker.d.ts +241 -0
- package/types/lib/mega-cron.d.ts +66 -0
- package/types/lib/mega-hash.d.ts +32 -0
- package/types/lib/mega-health.d.ts +41 -0
- package/types/lib/mega-job-queue.d.ts +176 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +138 -0
- package/types/lib/mega-logger.d.ts +45 -0
- package/types/lib/mega-metrics.d.ts +285 -0
- package/types/lib/mega-plugin.d.ts +245 -0
- package/types/lib/mega-retry.d.ts +85 -0
- package/types/lib/mega-schedule.d.ts +260 -0
- package/types/lib/mega-shutdown.d.ts +135 -0
- package/types/lib/mega-tracing.d.ts +224 -0
- package/types/lib/mega-worker.d.ts +127 -0
- package/types/lib/otel-resource.d.ts +16 -0
- package/types/lib/worker-runner/process-entry.d.ts +1 -0
- package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
- package/types/lib/worker-runner/thread-entry.d.ts +1 -0
- package/types/lib/ws-hub.d.ts +234 -0
- package/types/models/crud-sql-builder.d.ts +48 -0
- package/types/models/index.d.ts +1 -0
- package/types/models/mega-model.d.ts +138 -0
- package/types/models/model-crud.d.ts +82 -0
- package/types/models/mongo-crud.d.ts +59 -0
- package/types/test/index.d.ts +84 -0
- package/.env +0 -127
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
- package/sample/crud/apps/main/models/note.js +0 -71
- package/sample/crud/apps/main/models/user.js +0 -86
- package/sample/crud/package-lock.json +0 -5665
- package/sample/crud/yarn.lock +0 -2142
- package/sample/simple/package-lock.json +0 -1851
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaModel } from 'mega-framework'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UserModel — `users` 테이블(globalKey 'primary' 의 postgres). `static schema`(ADR-204) 선언으로
|
|
6
|
+
* 공통 CRUD(ADR-212)를 켜고 도메인 메서드 본문을 그 CRUD 로 작성한다. 복잡 쿼리(now() 등)는 계측
|
|
7
|
+
* raw `this.query`(ADR-138)와 공존. 모델은 서비스만 import 한다(라우트·컨트롤러 직접 import 는
|
|
8
|
+
* `mega/no-direct-model-import` 가 차단, ADR-022).
|
|
9
|
+
*
|
|
10
|
+
* @typedef {{ id: number|string, username: string, name: string, nickname: string, email: string|null, phone_number: string|null, type_code: string, level_code: string, last_login_at: string|null, created_at: string }} UserRow
|
|
11
|
+
* @typedef {{ id: number|string, username: string, name: string, password_hash: string | null }} UserAuthRow
|
|
12
|
+
*/
|
|
13
|
+
export class UserModel extends MegaModel {
|
|
14
|
+
static adapter = 'primary'
|
|
15
|
+
static table = 'users'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 회원 테이블 스키마(ADR-204) — 손 DDL(20260606000001-create-users)의 `users` 와 동일 컬럼.
|
|
19
|
+
* 선언 시 마이그레이션 자동 생성 + 공통 CRUD(ADR-212)가 함께 켜진다. type_code/level_code 는
|
|
20
|
+
* 원본대로 FK 없는 NOT NULL TEXT(기본값만).
|
|
21
|
+
*/
|
|
22
|
+
static schema = /** @param {any} t */ (t) => ({
|
|
23
|
+
id: t.bigSerial().primary(),
|
|
24
|
+
uuid: t.uuid().notNull().default({ raw: 'gen_random_uuid()' }).comment('고유값'),
|
|
25
|
+
username: t.text().notNull().unique().comment('로그인 아이디'),
|
|
26
|
+
password_hash: t.text().notNull().comment('비밀번호 해시'),
|
|
27
|
+
type_code: t.text().notNull().default('USER').comment('회원 유형'),
|
|
28
|
+
level_code: t.text().notNull().default('lv1').comment('회원 등급'),
|
|
29
|
+
name: t.text().notNull().comment('실명'),
|
|
30
|
+
nickname: t.text().notNull().unique().comment('별명'),
|
|
31
|
+
email: t.text().comment('이메일'),
|
|
32
|
+
phone_number: t.text().comment('전화번호'),
|
|
33
|
+
last_login_at: t.timestamptz().comment('마지막 로그인 시간'),
|
|
34
|
+
created_at: t.timestamptz().notNull().default({ raw: 'now()' }).comment('생성 시간'),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
static indexes = /** @param {any} t */ (t) => [
|
|
38
|
+
t.index(['uuid'], { unique: true, name: 'idx_users_uuid' }),
|
|
39
|
+
t.index(['email'], { name: 'idx_users_email' }),
|
|
40
|
+
t.index(['type_code'], { name: 'idx_users_type_code' }),
|
|
41
|
+
t.index(['level_code'], { name: 'idx_users_level_code' }),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 공개 컬럼 화이트리스트 — 조회 응답에 노출하는 컬럼(`password_hash`·`uuid` 제외). CRUD select 에 쓴다.
|
|
46
|
+
* @type {string[]}
|
|
47
|
+
*/
|
|
48
|
+
static PUBLIC_COLUMNS = ['id', 'username', 'name', 'nickname', 'email', 'phone_number', 'type_code', 'level_code', 'last_login_at', 'created_at']
|
|
49
|
+
|
|
50
|
+
/** 목록 — 공개 컬럼만, id 오름차순(ADR-212 findMany). @returns {Promise<UserRow[]>} */
|
|
51
|
+
static async list() {
|
|
52
|
+
return this.findMany({}, { select: this.PUBLIC_COLUMNS, orderBy: 'id' })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 전체 사용자 수 — /demo/redis 캐시 데모가 캐싱한다. 도메인 메서드명 `count` 가 CRUD `count` 와
|
|
57
|
+
* 충돌하므로 `super.count()`(부모=ADR-212 CRUD)로 호출한다(자기 재귀 회피). @returns {Promise<number>}
|
|
58
|
+
*/
|
|
59
|
+
static async count() {
|
|
60
|
+
return super.count()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* id 단건(공개 컬럼). 이름 충돌 회피로 `super.findById`(CRUD)에 위임. @param {number|string} id
|
|
65
|
+
* @returns {Promise<UserRow | null>}
|
|
66
|
+
*/
|
|
67
|
+
static async findById(id) {
|
|
68
|
+
return super.findById(Number(id), { select: this.PUBLIC_COLUMNS })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* username 으로 단건(공개 컬럼). 로그인 폼·중복 확인용. @param {string} username @returns {Promise<UserRow | null>}
|
|
73
|
+
*/
|
|
74
|
+
static async findByUsername(username) {
|
|
75
|
+
return this.findOne({ username }, { select: this.PUBLIC_COLUMNS })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 새 사용자 생성(관리자/회원가입 공용). 필수: username·passwordHash·name·nickname. 선택: email·
|
|
80
|
+
* phone_number·type_code·level_code(미지정 시 DB 기본값 USER/lv1). 반환은 공개 컬럼만(해시 미노출).
|
|
81
|
+
* @param {{ username: string, passwordHash: string, name: string, nickname: string, email?: string|null, phone_number?: string|null, type_code?: string, level_code?: string }} input
|
|
82
|
+
* @returns {Promise<UserRow>}
|
|
83
|
+
*/
|
|
84
|
+
static async create(input) {
|
|
85
|
+
/** @type {Record<string, any>} */
|
|
86
|
+
const data = {
|
|
87
|
+
username: input.username,
|
|
88
|
+
password_hash: input.passwordHash,
|
|
89
|
+
name: input.name,
|
|
90
|
+
nickname: input.nickname,
|
|
91
|
+
email: input.email ?? null,
|
|
92
|
+
phone_number: input.phone_number ?? null,
|
|
93
|
+
}
|
|
94
|
+
if (input.type_code) data.type_code = input.type_code
|
|
95
|
+
if (input.level_code) data.level_code = input.level_code
|
|
96
|
+
const id = await this.insertOne(data) // 기본 returning=false → 새 id
|
|
97
|
+
return /** @type {Promise<UserRow>} */ (this.findById(id))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 회원가입(/register) — create 와 동일 본문(자동 로그인 흐름은 컨트롤러 책임). 메서드명 유지.
|
|
102
|
+
* @param {{ username: string, passwordHash: string, name: string, nickname: string, email?: string|null, phone_number?: string|null }} input
|
|
103
|
+
* @returns {Promise<UserRow>}
|
|
104
|
+
*/
|
|
105
|
+
static async register(input) {
|
|
106
|
+
return this.create(input)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 부분 수정 — 허용 컬럼(name·nickname·email·phone_number·type_code·level_code)만. username·password 는
|
|
111
|
+
* 별도 흐름(여기서 안 바꿈). 변경 없으면 현재 행 반환. PK filter 라 0|1 매칭(updateOne, ADR-212 #3).
|
|
112
|
+
* @param {number|string} id @param {Record<string, any>} patch @returns {Promise<UserRow | null>}
|
|
113
|
+
*/
|
|
114
|
+
static async update(id, patch) {
|
|
115
|
+
/** @type {Record<string, any>} */
|
|
116
|
+
const fields = {}
|
|
117
|
+
for (const k of ['name', 'nickname', 'email', 'phone_number', 'type_code', 'level_code']) {
|
|
118
|
+
if (patch?.[k] !== undefined) fields[k] = patch[k]
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(fields).length === 0) return this.findById(id) // 변경 없음 — 현재 행
|
|
121
|
+
const n = await this.updateOne({ id: Number(id) }, fields)
|
|
122
|
+
return n > 0 ? this.findById(id) : null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 삭제 — PK filter(deleteOne, 0|1). @param {number|string} id @returns {Promise<boolean>} 삭제 여부. */
|
|
126
|
+
static async remove(id) {
|
|
127
|
+
return (await this.deleteOne({ id: Number(id) })) > 0
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 로그인 검증용 — username 으로 사용자를 찾아 password_hash 까지 돌려준다. 해시가 필요한 인증 경로에서만
|
|
132
|
+
* 쓴다(일반 조회 list/findById 는 해시 미선택 → 노출 없음). @param {string} username
|
|
133
|
+
* @returns {Promise<UserAuthRow | null>}
|
|
134
|
+
*/
|
|
135
|
+
static async findByUsernameWithHash(username) {
|
|
136
|
+
return this.findOne({ username }, { select: ['id', 'username', 'name', 'password_hash'] })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 로그인 성공 시각 갱신. `now()` 는 SQL 함수라 raw 쿼리 유지(ADR-212 CRUD 는 값 바인딩만 — 오너 합의).
|
|
141
|
+
* @param {number|string} id @returns {Promise<void>}
|
|
142
|
+
*/
|
|
143
|
+
static async touchLastLogin(id) {
|
|
144
|
+
await this.query('UPDATE users SET last_login_at = now() WHERE id = $1', [Number(id)])
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaModel } from 'mega-framework'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UserTypeModel — `user_types`(회원 유형 lookup). `static schema`(ADR-204) 로 마이그레이션 자동 생성 +
|
|
6
|
+
* 공통 CRUD(ADR-212) 를 함께 켠다. 기존 손 DDL(20260606000001-create-users)의 user_types 와 동일 형태.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {{ id: number, code: string, sort_order: number, name: string, created_at: string }} UserTypeRow
|
|
9
|
+
*/
|
|
10
|
+
export class UserTypeModel extends MegaModel {
|
|
11
|
+
static adapter = 'primary'
|
|
12
|
+
static table = 'user_types'
|
|
13
|
+
|
|
14
|
+
static schema = /** @param {any} t */ (t) => ({
|
|
15
|
+
id: t.serial().primary(),
|
|
16
|
+
code: t.text().notNull().unique().comment('회원 유형 코드'),
|
|
17
|
+
sort_order: t.integer().notNull().default(0).comment('정렬 순서'),
|
|
18
|
+
name: t.text().notNull().comment('회원 유형명'),
|
|
19
|
+
created_at: t.timestamptz().notNull().default({ raw: 'now()' }).comment('생성 시간'),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaModel } from 'mega-framework'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WalletModel — `wallets`(회원 지갑). user_id 는 users(id) FK(ON DELETE CASCADE). idx_wallets_user_id 동반.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ balance: 원본 손 DDL 은 무정밀 `DECIMAL`(임의 정밀도) 이지만 schema DSL 은 `decimal(p, s)` 만
|
|
8
|
+
* 표현 가능 → `NUMERIC(20, 4)` 로 **명시**(오너 승인 필요한 의도적 타입 명세). 정밀도 정책은 변경 가능.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {{ id: number, user_id: number, balance: string, created_at: string }} WalletRow
|
|
11
|
+
*/
|
|
12
|
+
export class WalletModel extends MegaModel {
|
|
13
|
+
static adapter = 'primary'
|
|
14
|
+
static table = 'wallets'
|
|
15
|
+
|
|
16
|
+
static schema = /** @param {any} t */ (t) => ({
|
|
17
|
+
id: t.serial().primary(),
|
|
18
|
+
user_id: t.bigInteger().notNull().references('UserModel', 'id', { onDelete: 'cascade' }).comment('회원 ID'),
|
|
19
|
+
balance: t.decimal(20, 4).notNull().default(0).comment('잔액'),
|
|
20
|
+
created_at: t.timestamptz().notNull().default({ raw: 'now()' }).comment('생성 시간'),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
static indexes = /** @param {any} t */ (t) => [t.index(['user_id'], { name: 'idx_wallets_user_id' })]
|
|
24
|
+
}
|
|
@@ -11,44 +11,89 @@ import { requireAuth } from 'mega-framework/auth'
|
|
|
11
11
|
import { UserController } from '../controllers/user-controller.js'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* users REST 라우트의 OpenAPI
|
|
15
|
-
*
|
|
16
|
-
* (
|
|
14
|
+
* users REST 라우트의 schema + OpenAPI 메타(ADR-070/140/163/184).
|
|
15
|
+
*
|
|
16
|
+
* body schema 는 **강제 검증**이다(ADR-019 — 1차 검증은 Fastify 네이티브 AJV). 형식 위반은 핸들러
|
|
17
|
+
* 진입 전에 400 `validation.failed` envelope 로 거부되고, 서비스의 도메인 검증(user.invalid 등)은
|
|
18
|
+
* 2차 방어로 남는다. JSON API 는 CSRF 토큰 면제(Origin 검증, ADR-051)라 `_csrf` 필드가 없어
|
|
19
|
+
* `additionalProperties:false` 를 쓸 수 있다(폼 라우트와 다른 점). 단 Fastify 디폴트 AJV 는
|
|
20
|
+
* `removeAdditional:true` 라 선언 외 필드는 **거부가 아니라 제거**된다(입력 sanitize — 실측 확인).
|
|
21
|
+
*
|
|
22
|
+
* response schema 는 raw row 모양으로 선언한다 — 프레임워크가 envelope(`{ok,data,meta}`) 모양으로
|
|
23
|
+
* 자동 합성(ADR-184)하므로 선언 외 필드는 data 안에서 제거되고(ADR-020 strict — 우발 노출 방지)
|
|
24
|
+
* OpenAPI 명세도 실제 응답과 일치한다.
|
|
17
25
|
*/
|
|
18
26
|
const idParams = { type: 'object', properties: { id: { type: 'string', description: '사용자 id' } } }
|
|
19
|
-
const
|
|
27
|
+
const userCreateBody = {
|
|
28
|
+
type: 'object',
|
|
29
|
+
required: ['username', 'name', 'nickname', 'password'],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
properties: {
|
|
32
|
+
username: { type: 'string', minLength: 1, description: '로그인 아이디' },
|
|
33
|
+
password: { type: 'string', minLength: 8, description: '비밀번호(8자 이상)' },
|
|
34
|
+
name: { type: 'string', minLength: 1, description: '실명' },
|
|
35
|
+
nickname: { type: 'string', minLength: 1, description: '별명' },
|
|
36
|
+
email: { type: 'string', format: 'email', description: '이메일(선택)' },
|
|
37
|
+
phone_number: { type: 'string', description: '전화번호(선택)' },
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
const userPatchBody = {
|
|
20
41
|
type: 'object',
|
|
42
|
+
additionalProperties: false,
|
|
21
43
|
properties: {
|
|
22
|
-
name: { type: 'string', description: '
|
|
44
|
+
name: { type: 'string', minLength: 1, description: '실명' },
|
|
45
|
+
nickname: { type: 'string', minLength: 1, description: '별명' },
|
|
23
46
|
email: { type: 'string', format: 'email', description: '이메일' },
|
|
47
|
+
phone_number: { type: 'string', description: '전화번호' },
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
/** users 테이블 공개 row 응답 모양 (password_hash·uuid 제외 — UserModel.PUBLIC_COLUMNS 와 정합). */
|
|
51
|
+
const userRow = {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
id: { type: 'string', description: '사용자 id(bigint → 문자열)' },
|
|
55
|
+
username: { type: 'string', description: '로그인 아이디' },
|
|
56
|
+
name: { type: 'string', description: '실명' },
|
|
57
|
+
nickname: { type: 'string', description: '별명' },
|
|
58
|
+
email: { type: ['string', 'null'], description: '이메일' },
|
|
59
|
+
phone_number: { type: ['string', 'null'], description: '전화번호' },
|
|
60
|
+
type_code: { type: 'string', description: '회원 유형' },
|
|
61
|
+
level_code: { type: 'string', description: '회원 등급' },
|
|
62
|
+
last_login_at: { type: ['string', 'null'], description: '마지막 로그인(ISO)' },
|
|
63
|
+
created_at: { type: 'string', description: '생성 시각(ISO)' },
|
|
24
64
|
},
|
|
25
65
|
}
|
|
66
|
+
const userList = { type: 'array', items: userRow }
|
|
26
67
|
|
|
27
68
|
export default (/** @type {any} */ router) => {
|
|
28
69
|
/** @type {{ before: Function[] }} REST API 인증 가드(401 throw). */
|
|
29
70
|
const guarded = { before: [requireAuth] }
|
|
30
71
|
router.http.get('/users', UserController.index, {
|
|
31
72
|
...guarded,
|
|
73
|
+
schema: { response: { 200: userList } },
|
|
32
74
|
openapi: { tags: ['users'], summary: '사용자 목록', description: '모든 사용자를 반환한다.' },
|
|
33
75
|
})
|
|
34
76
|
router.http.get('/users/:id', UserController.show, {
|
|
35
77
|
...guarded,
|
|
36
|
-
schema: { params: idParams },
|
|
78
|
+
schema: { params: idParams, response: { 200: userRow } },
|
|
37
79
|
openapi: { tags: ['users'], summary: '사용자 조회', description: 'id 로 단일 사용자를 반환한다.' },
|
|
38
80
|
})
|
|
39
81
|
router.http.post('/users', UserController.create, {
|
|
40
82
|
...guarded,
|
|
41
|
-
schema: { body:
|
|
42
|
-
openapi: { tags: ['users'], summary: '사용자 생성', description: 'name·
|
|
83
|
+
schema: { body: userCreateBody, response: { 201: userRow } },
|
|
84
|
+
openapi: { tags: ['users'], summary: '사용자 생성', description: 'username·password·name·nickname 으로 사용자를 생성한다(email·phone 선택).' },
|
|
43
85
|
})
|
|
44
86
|
router.http.put('/users/:id', UserController.update, {
|
|
45
87
|
...guarded,
|
|
46
|
-
schema: { params: idParams, body:
|
|
88
|
+
schema: { params: idParams, body: userPatchBody, response: { 200: userRow } },
|
|
47
89
|
openapi: { tags: ['users'], summary: '사용자 수정', description: 'id 사용자를 수정한다.' },
|
|
48
90
|
})
|
|
49
91
|
router.http.delete('/users/:id', UserController.destroy, {
|
|
50
92
|
...guarded,
|
|
51
|
-
schema: {
|
|
93
|
+
schema: {
|
|
94
|
+
params: idParams,
|
|
95
|
+
response: { 200: { type: 'object', properties: { deleted: { type: 'boolean' } } } },
|
|
96
|
+
},
|
|
52
97
|
openapi: { tags: ['users'], summary: '사용자 삭제', description: 'id 사용자를 삭제한다.' },
|
|
53
98
|
})
|
|
54
99
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaSchedule } from 'mega-framework'
|
|
3
|
+
import { LogPartitionService } from '../services/log-partition-service.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* LogPartitionSchedule — 로그 테이블 월별 파티셔닝 정기 관리 스케줄러 (ADR-028/118).
|
|
7
|
+
* `mega scheduler` 프로세스가 백그라운드에서 실행하며, 분산 락을 통해 멀티 인스턴스 환경에서 중복 실행을 예방합니다.
|
|
8
|
+
*
|
|
9
|
+
* 매일 새벽 2시에 작동하여 다음 달/다다음 달 파티션을 미리 생성해두고, 1개월 보존 기간이 만료된 지지난 달 파티션을 정리합니다.
|
|
10
|
+
*/
|
|
11
|
+
export class LogPartitionSchedule extends MegaSchedule {
|
|
12
|
+
// 매일 새벽 2시 0분에 작동하는 cron 식
|
|
13
|
+
static cron = '0 2 * * *'
|
|
14
|
+
// 실행 기준 타임존
|
|
15
|
+
static timezone = 'Asia/Seoul'
|
|
16
|
+
// 분산 락 설정 — lock 어댑터는 config.locks.main 을 공유하고, key 는 MegaScheduler 가
|
|
17
|
+
// 'mega:schedule:LogPartitionSchedule' 로 자동 생성하므로 다른 스케줄과 충돌 없음.
|
|
18
|
+
// TTL 은 DDL 수행 시간(수백 ms)보다 넉넉하게 1분으로 설정.
|
|
19
|
+
static lock = { lock: 'main', ttl: 60_000 }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 스케줄러 주기마다 호출되는 메인 비즈니스 함수입니다.
|
|
23
|
+
* @param {Record<string, any>} ctx - scheduler 프로세스 컨텍스트
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
async run(ctx) {
|
|
27
|
+
ctx.log?.info?.('[LogPartitionSchedule] 스케줄링 태스크가 트리거되었습니다.')
|
|
28
|
+
|
|
29
|
+
// 파티션 서비스 인스턴스를 생성하고 관리 프로세스 실행
|
|
30
|
+
const partitionService = new LogPartitionService(ctx)
|
|
31
|
+
await partitionService.manage()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { MegaService } from 'mega-framework'
|
|
3
3
|
import { MegaHash } from 'mega-framework'
|
|
4
4
|
import { MegaValidationError, MegaConflictError } from 'mega-framework/errors'
|
|
5
|
-
import {
|
|
5
|
+
import { UserModel } from '../models/user-model.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* AuthService — 회원가입·로그인 검증 로직(ADR-155). 파일명 `auth-service.js` → 자동 DI 이름 `auth`
|
|
9
9
|
* (ctx.services.auth, ADR-148). 비밀번호는 scrypt(MegaHash, ADR-130)로 해시해 저장하고, 검증은 상수
|
|
10
|
-
* 시간 비교로 한다 — 평문은 어디에도 저장·로그하지 않는다.
|
|
10
|
+
* 시간 비교로 한다 — 평문은 어디에도 저장·로그하지 않는다. 로그인 식별자는 **username**(users.username,
|
|
11
|
+
* unique) 이다 — email 은 선택 입력이라 로그인 키로 쓰지 않는다.
|
|
11
12
|
*
|
|
12
13
|
* brute-force(반복 시도 잠금)는 이 서비스가 아니라 컨트롤러가 `ctx.bruteForce`(redis, ADR-049/130)로
|
|
13
14
|
* 다룬다 — 잠금은 HTTP 요청(IP)·세션 흐름에 묶여 있어 컨트롤러 책임이기 때문이다.
|
|
@@ -16,59 +17,73 @@ export class AuthService extends MegaService {
|
|
|
16
17
|
/** 비밀번호 최소 길이(OWASP Password Storage Cheat Sheet — 최소 8자 권장). */
|
|
17
18
|
static MIN_PASSWORD = 8
|
|
18
19
|
|
|
20
|
+
/** 회원가입 필수 필드(전부 users 테이블 NOT NULL). */
|
|
21
|
+
static REQUIRED = ['username', 'name', 'nickname']
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
|
-
* 새 계정을 만든다. name·
|
|
21
|
-
*
|
|
22
|
-
* @
|
|
24
|
+
* 새 계정을 만든다. username·name·nickname·password 를 검증하고 비밀번호를 해시해 저장한다.
|
|
25
|
+
* email·phone_number 는 선택(빈 값 허용). type_code/level_code 는 DB 기본값(USER/lv1).
|
|
26
|
+
* @param {{ username?: unknown, name?: unknown, nickname?: unknown, email?: unknown, phone_number?: unknown, password?: unknown }} input
|
|
27
|
+
* @returns {Promise<import('../models/user-model.js').UserRow>}
|
|
23
28
|
* @throws {MegaValidationError} `auth.invalid` - 필수값 누락 또는 비밀번호가 너무 짧음.
|
|
24
|
-
* @throws {MegaConflictError} `user.
|
|
29
|
+
* @throws {MegaConflictError} `user.username_taken` | `user.nickname_taken` - 중복.
|
|
25
30
|
*/
|
|
26
31
|
async register(input) {
|
|
32
|
+
const username = typeof input?.username === 'string' ? input.username.trim() : ''
|
|
27
33
|
const name = typeof input?.name === 'string' ? input.name.trim() : ''
|
|
34
|
+
const nickname = typeof input?.nickname === 'string' ? input.nickname.trim() : ''
|
|
28
35
|
const email = typeof input?.email === 'string' ? input.email.trim().toLowerCase() : ''
|
|
36
|
+
const phone = typeof input?.phone_number === 'string' ? input.phone_number.trim() : ''
|
|
29
37
|
const password = typeof input?.password === 'string' ? input.password : ''
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
|
|
39
|
+
const valid = { username: !!username, name: !!name, nickname: !!nickname, password: password.length >= AuthService.MIN_PASSWORD }
|
|
40
|
+
if (!username || !name || !nickname) {
|
|
41
|
+
throw new MegaValidationError('auth.invalid', 'username, name, nickname are required', { details: valid })
|
|
32
42
|
}
|
|
33
43
|
if (password.length < AuthService.MIN_PASSWORD) {
|
|
34
|
-
throw new MegaValidationError('auth.invalid', `password must be at least ${AuthService.MIN_PASSWORD} characters`, { details:
|
|
44
|
+
throw new MegaValidationError('auth.invalid', `password must be at least ${AuthService.MIN_PASSWORD} characters`, { details: valid })
|
|
35
45
|
}
|
|
46
|
+
|
|
36
47
|
const passwordHash = await MegaHash.password.hash(password)
|
|
37
|
-
this.log.debug?.({
|
|
48
|
+
this.log.debug?.({ username }, 'auth.register')
|
|
38
49
|
try {
|
|
39
|
-
return await
|
|
50
|
+
return await UserModel.register({ username, name, nickname, email: email || null, phone_number: phone || null, passwordHash })
|
|
40
51
|
} catch (err) {
|
|
41
|
-
//
|
|
52
|
+
// unique_violation(23505) → 어느 제약인지로 username/nickname 충돌을 구분(409 매핑, P4 — 명시 처리 후 throw).
|
|
42
53
|
if (/** @type {any} */ (err)?.code === '23505') {
|
|
43
|
-
|
|
54
|
+
const constraint = String(/** @type {any} */ (err)?.constraint ?? '')
|
|
55
|
+
if (constraint.includes('nickname')) {
|
|
56
|
+
throw new MegaConflictError('user.nickname_taken', `nickname '${nickname}' already exists`, { details: { nickname }, cause: err })
|
|
57
|
+
}
|
|
58
|
+
throw new MegaConflictError('user.username_taken', `username '${username}' already exists`, { details: { username }, cause: err })
|
|
44
59
|
}
|
|
45
60
|
throw err
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
/**
|
|
50
|
-
*
|
|
51
|
-
* 실패(없는
|
|
65
|
+
* username+비밀번호를 검증한다. 일치하면 마지막 로그인 시각을 갱신하고 사용자 식별 정보를 돌려준다.
|
|
66
|
+
* 실패(없는 username·비밀번호 미설정 계정·불일치)는 **이유를 구분하지 않고** null 을 돌려준다
|
|
52
67
|
* (user enumeration 방지 — 어느 쪽이 틀렸는지 노출하지 않는다, OWASP Authentication Cheat Sheet).
|
|
53
|
-
* @param {{
|
|
54
|
-
* @returns {Promise<{ id: number, name: string } | null>}
|
|
68
|
+
* @param {{ username?: unknown, password?: unknown }} input
|
|
69
|
+
* @returns {Promise<{ id: number|string, name: string } | null>}
|
|
55
70
|
*/
|
|
56
71
|
async authenticate(input) {
|
|
57
|
-
const
|
|
72
|
+
const username = typeof input?.username === 'string' ? input.username.trim() : ''
|
|
58
73
|
const password = typeof input?.password === 'string' ? input.password : ''
|
|
59
|
-
if (!
|
|
60
|
-
const row = await
|
|
61
|
-
this.log.debug?.({
|
|
74
|
+
if (!username || !password) return null
|
|
75
|
+
const row = await UserModel.findByUsernameWithHash(username)
|
|
76
|
+
this.log.debug?.({ username, found: row !== null }, 'auth.authenticate lookup')
|
|
62
77
|
if (row === null || typeof row.password_hash !== 'string') {
|
|
63
|
-
// 없는
|
|
78
|
+
// 없는 username 이거나 비밀번호 미설정 — 둘 다 로그인 불가.
|
|
64
79
|
return null
|
|
65
80
|
}
|
|
66
81
|
const ok = await MegaHash.password.verify(password, row.password_hash)
|
|
67
82
|
if (!ok) {
|
|
68
|
-
this.log.debug?.({
|
|
83
|
+
this.log.debug?.({ username }, 'auth.authenticate mismatch')
|
|
69
84
|
return null
|
|
70
85
|
}
|
|
71
|
-
await
|
|
86
|
+
await UserModel.touchLastLogin(row.id)
|
|
72
87
|
return { id: row.id, name: row.name }
|
|
73
88
|
}
|
|
74
89
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { MegaService } from 'mega-framework'
|
|
3
|
+
import { LogPartitionModel } from '../models/log-partition-model.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 로그 파티션 자동 관리 서비스 클래스 (ADR-148).
|
|
7
|
+
* 매월 데이터베이스 파티션을 미리 확보하고 오래된 파티션을 드롭(Drop)하는 핵심 비즈니스 로직을 포함합니다.
|
|
8
|
+
* @extends {MegaService}
|
|
9
|
+
*/
|
|
10
|
+
export class LogPartitionService extends MegaService {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Record<string, any>} ctx - 요청/스케줄러 컨텍스트
|
|
13
|
+
*/
|
|
14
|
+
constructor(ctx) {
|
|
15
|
+
super(ctx)
|
|
16
|
+
/** @type {Record<string, any>} 요청/스케줄러 컨텍스트 (MegaService 기본 바인딩 — 타입 힌트용 재선언) */
|
|
17
|
+
this.ctx = ctx
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 파티셔닝 대상 테이블 리스트 */
|
|
21
|
+
static TARGET_TABLES = ['action_logs', 'wallet_logs', 'detail_logs']
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 현재 월(M) 및 미래 파티션(M+1, M+2)을 자동 생성합니다.
|
|
25
|
+
* 현재 월을 포함하여 항상 3개월 치를 보장함으로써, 스케줄러가 장기간 중단된 후
|
|
26
|
+
* 복구되더라도 INSERT 가 실패하지 않도록 방어합니다.
|
|
27
|
+
* @param {Date} baseDate - 기준 날짜 (기본값: 오늘)
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
async ensurePartitions(baseDate = new Date()) {
|
|
31
|
+
// 현재 월(M) — 반드시 존재해야 INSERT 가 라우팅 됨
|
|
32
|
+
const m0 = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1)
|
|
33
|
+
// 다음 달(M+1) 계산
|
|
34
|
+
const m1 = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 1)
|
|
35
|
+
// 다다음 달(M+2) 계산
|
|
36
|
+
const m2 = new Date(baseDate.getFullYear(), baseDate.getMonth() + 2, 1)
|
|
37
|
+
|
|
38
|
+
// 각 대상 테이블마다 현재/미래 파티션 존재 여부를 확인하고 생성
|
|
39
|
+
for (const table of LogPartitionService.TARGET_TABLES) {
|
|
40
|
+
this.ctx.log?.info?.(`[LogPartition] ${table} 테이블의 파티션 보장 중 (M, M+1, M+2)...`)
|
|
41
|
+
|
|
42
|
+
// 현재 월 파티션 보장 (CREATE TABLE IF NOT EXISTS 이므로 이미 있으면 무시됨)
|
|
43
|
+
await LogPartitionModel.createPartition(table, m0.getFullYear(), m0.getMonth() + 1)
|
|
44
|
+
// 다음 달 파티션 생성
|
|
45
|
+
await LogPartitionModel.createPartition(table, m1.getFullYear(), m1.getMonth() + 1)
|
|
46
|
+
// 다다음 달 파티션 생성
|
|
47
|
+
await LogPartitionModel.createPartition(table, m2.getFullYear(), m2.getMonth() + 1)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 보관 주기(1달)가 초과된 과거 파티션(지지난 달 M-2 이하)을 자동 분리 후 삭제합니다.
|
|
53
|
+
* @param {Date} baseDate - 기준 날짜 (기본값: 오늘)
|
|
54
|
+
* @returns {Promise<void>}
|
|
55
|
+
*/
|
|
56
|
+
async cleanupPastPartitions(baseDate = new Date()) {
|
|
57
|
+
// "한 달 보관" 기준: 이번 달(M)과 지난 달(M-1)은 보존하고,
|
|
58
|
+
// 지지난 달(M-2)의 1일 이전 데이터를 포함하는 파티션을 삭제 대상으로 판단함.
|
|
59
|
+
const cutoffDate = new Date(baseDate.getFullYear(), baseDate.getMonth() - 2, 1)
|
|
60
|
+
const cutoffYear = cutoffDate.getFullYear()
|
|
61
|
+
const cutoffMonth = cutoffDate.getMonth() + 1
|
|
62
|
+
|
|
63
|
+
for (const table of LogPartitionService.TARGET_TABLES) {
|
|
64
|
+
// 실제 존재하는 파티션 목록 조회
|
|
65
|
+
const partitions = await LogPartitionModel.getPartitions(table)
|
|
66
|
+
|
|
67
|
+
for (const pName of partitions) {
|
|
68
|
+
// 파티션 이름에서 연도와 월 파싱 (예: action_logs_y2026m04 -> 2026, 4)
|
|
69
|
+
const match = pName.match(/_y(\d{4})m(\d{2})$/)
|
|
70
|
+
if (!match) continue
|
|
71
|
+
|
|
72
|
+
const pYear = parseInt(match[1], 10)
|
|
73
|
+
const pMonth = parseInt(match[2], 10)
|
|
74
|
+
|
|
75
|
+
// 파티션의 연월이 정리 기준선(cutoff) 이하인 경우 삭제 진행
|
|
76
|
+
const isExpired = pYear < cutoffYear || (pYear === cutoffYear && pMonth <= cutoffMonth)
|
|
77
|
+
if (isExpired) {
|
|
78
|
+
this.ctx.log?.info?.(`[LogPartition] 만료된 파티션 정리 중: ${table} -> ${pName}`)
|
|
79
|
+
// 안전하게 분리 후 삭제 진행
|
|
80
|
+
await LogPartitionModel.dropPartition(table, pName)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 파티션 생성 및 정리 작업을 통합 관리하는 메인 서비스 진입점입니다.
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async manage() {
|
|
91
|
+
this.ctx.log?.info?.('[LogPartition] 로그 테이블 파티션 관리 작업을 시작합니다.')
|
|
92
|
+
|
|
93
|
+
// 1. 현재 월 + 미래 파티션 보장 (M, M+1, M+2)
|
|
94
|
+
await this.ensurePartitions()
|
|
95
|
+
|
|
96
|
+
// 2. 만료된 과거 파티션 정리
|
|
97
|
+
await this.cleanupPastPartitions()
|
|
98
|
+
|
|
99
|
+
this.ctx.log?.info?.('[LogPartition] 로그 테이블 파티션 관리 작업을 완료했습니다.')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { MegaService } from 'mega-framework'
|
|
3
3
|
import { MegaNotFoundError, MegaValidationError } from 'mega-framework/errors'
|
|
4
|
-
import {
|
|
4
|
+
import { NoteModel } from '../models/note-model.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* NoteService — note 도메인 비즈니스 로직(ADR-022). 컨트롤러는 모델을 직접 만지지 않고 이 서비스를 거친다.
|
|
@@ -16,12 +16,12 @@ export class NoteService extends MegaService {
|
|
|
16
16
|
/** @returns {Promise<object[]>} */
|
|
17
17
|
async list() {
|
|
18
18
|
this.log.debug?.('note.list')
|
|
19
|
-
return
|
|
19
|
+
return NoteModel.list()
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** @param {string} id @returns {Promise<object>} @throws {MegaNotFoundError} */
|
|
23
23
|
async get(id) {
|
|
24
|
-
const note = await
|
|
24
|
+
const note = await NoteModel.findById(String(id))
|
|
25
25
|
if (!note) throw new MegaNotFoundError('note.not_found', `Note ${id} not found`, { details: { id } })
|
|
26
26
|
return note
|
|
27
27
|
}
|
|
@@ -34,7 +34,7 @@ export class NoteService extends MegaService {
|
|
|
34
34
|
async create(input) {
|
|
35
35
|
const { title, body } = NoteService.#normalize(input)
|
|
36
36
|
this.log.debug?.({ title }, 'note.create')
|
|
37
|
-
return
|
|
37
|
+
return NoteModel.create({ title, body })
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -44,14 +44,14 @@ export class NoteService extends MegaService {
|
|
|
44
44
|
*/
|
|
45
45
|
async update(id, patch) {
|
|
46
46
|
const { title, body } = NoteService.#normalize(patch)
|
|
47
|
-
const updated = await
|
|
47
|
+
const updated = await NoteModel.update(String(id), { title, body })
|
|
48
48
|
if (!updated) throw new MegaNotFoundError('note.not_found', `Note ${id} not found`, { details: { id } })
|
|
49
49
|
return updated
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** @param {string} id @returns {Promise<{ deleted: true }>} @throws {MegaNotFoundError} */
|
|
53
53
|
async remove(id) {
|
|
54
|
-
const ok = await
|
|
54
|
+
const ok = await NoteModel.remove(String(id))
|
|
55
55
|
if (!ok) throw new MegaNotFoundError('note.not_found', `Note ${id} not found`, { details: { id } })
|
|
56
56
|
return { deleted: true }
|
|
57
57
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { MegaService } from 'mega-framework'
|
|
3
|
-
import {
|
|
3
|
+
import { UserModel } from '../models/user-model.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* RedisDemoService — /demo/redis 데모 로직(ADR-157). 파일명 `redis-demo-service.js` → 자동 DI 이름
|
|
@@ -8,7 +8,7 @@ import { User } from '../models/user.js'
|
|
|
8
8
|
*
|
|
9
9
|
* 1) **방문 카운터** — 표준 캐시 표면(get/set)에 없는 원자적 INCR 가 필요하므로 `.native`(raw ioredis)로
|
|
10
10
|
* `INCR`/`EXPIRE` 를 직접 호출한다(ADR-009 escape hatch). 누적 카운터 + 일자별 카운터(TTL 2일).
|
|
11
|
-
* 2) **쿼리 결과 캐시** — 표준 표면 `get/set`(JSON 직렬화 + TTL)으로 SQL 카운트(
|
|
11
|
+
* 2) **쿼리 결과 캐시** — 표준 표면 `get/set`(JSON 직렬화 + TTL)으로 SQL 카운트(UserModel.count)를 30초 캐싱한다.
|
|
12
12
|
* hit/miss 와 남은 TTL(`.native.ttl`)을 함께 돌려줘 캐시 동작을 눈으로 보게 한다.
|
|
13
13
|
*
|
|
14
14
|
* 캐시 키는 'demo' 캐시 안에서 `demo:redis:*` 네임스페이스로 두어 다른 용도와 분리한다(같은 redis 인스턴스라도
|
|
@@ -56,7 +56,7 @@ export class RedisDemoService extends MegaService {
|
|
|
56
56
|
this.ctx.log?.debug?.({ value: cached, ttlSeconds }, 'redis-demo.cache hit')
|
|
57
57
|
return { value: cached, isHit: true, ttlSeconds }
|
|
58
58
|
}
|
|
59
|
-
const value = await
|
|
59
|
+
const value = await UserModel.count()
|
|
60
60
|
await cache.set(RedisDemoService.USER_COUNT_KEY, value, { ttl: RedisDemoService.USER_COUNT_TTL })
|
|
61
61
|
this.ctx.log?.debug?.({ value }, 'redis-demo.cache miss — recomputed from SQL')
|
|
62
62
|
return { value, isHit: false, ttlSeconds: RedisDemoService.USER_COUNT_TTL }
|