@cartanova/qgrid-cli 2.6.7 → 2.6.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.
- package/bundle/dist/application/monit/monit.frame.js +2 -2
- package/bundle/dist/application/qgrid/expired-token-reminder.js +45 -15
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid.frame.js +3 -3
- package/bundle/dist/application/qgrid/slack-user-map.js +5 -14
- package/bundle/dist/application/qgrid/token-death.js +3 -2
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/queries.generated.js +8 -2
- package/bundle/dist/application/request-log/request-log.model.js +2 -2
- package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
- package/bundle/dist/application/setting/setting.constant.js +226 -0
- package/bundle/dist/application/setting/setting.model.js +254 -0
- package/bundle/dist/application/setting/setting.store.js +66 -0
- package/bundle/dist/application/setting/setting.types.js +51 -0
- package/bundle/dist/application/sonamu.generated.js +32 -2
- package/bundle/dist/application/sonamu.generated.sso.js +12 -2
- package/bundle/dist/application/token/token.model.js +1 -1
- package/bundle/dist/i18n/en.js +3 -1
- package/bundle/dist/i18n/ko.js +3 -1
- package/bundle/dist/i18n/sd.generated.js +9 -1
- package/bundle/dist/migrations/20260808194708_create__settings.js +24 -0
- package/bundle/dist/sonamu.config.js +8 -2
- package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +21 -6
- package/bundle/dist/utils/quiet-hours.js +47 -0
- package/bundle/dist/utils/slack-notify.js +16 -5
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +147 -2
- package/bundle/src/application/qgrid/expired-token-reminder.ts +66 -16
- package/bundle/src/application/qgrid/slack-user-map.test.ts +23 -0
- package/bundle/src/application/qgrid/slack-user-map.ts +4 -14
- package/bundle/src/application/qgrid/token-death.ts +2 -0
- package/bundle/src/application/queries.generated.ts +21 -1
- package/bundle/src/application/setting/setting.constant.test.ts +109 -0
- package/bundle/src/application/setting/setting.constant.ts +250 -0
- package/bundle/src/application/setting/setting.entity.json +59 -0
- package/bundle/src/application/setting/setting.model.ts +277 -0
- package/bundle/src/application/setting/setting.store.test.ts +87 -0
- package/bundle/src/application/setting/setting.store.ts +85 -0
- package/bundle/src/application/setting/setting.types.ts +53 -0
- package/bundle/src/application/sonamu.generated.http +54 -0
- package/bundle/src/application/sonamu.generated.sso.ts +21 -0
- package/bundle/src/application/sonamu.generated.ts +51 -0
- package/bundle/src/i18n/en.ts +2 -0
- package/bundle/src/i18n/ko.ts +2 -0
- package/bundle/src/i18n/sd.generated.ts +8 -0
- package/bundle/src/migrations/20260808194708_create__settings.ts +19 -0
- package/bundle/src/sonamu.config.ts +12 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +12 -0
- package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +43 -5
- package/bundle/src/utils/quiet-hours.ts +79 -0
- package/bundle/src/utils/slack-notify.test.ts +171 -20
- package/bundle/src/utils/slack-notify.ts +31 -4
- package/bundle/web-dist/client/assets/index-DnnliAzf.js +78 -0
- package/bundle/web-dist/client/assets/index-rdbxJinT.css +1 -0
- package/bundle/web-dist/client/assets/{logs-CUO9Q0UT.js → logs-5gACO5tJ.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-Dy7UtqC2.js → monit-DRCc50GU.js} +1 -1
- package/bundle/web-dist/client/assets/plus-p4vtg6LN.js +1 -0
- package/bundle/web-dist/client/assets/{routes-B6gfQrph.js → routes-DBdI9Ba1.js} +2 -2
- package/bundle/web-dist/client/assets/{sd.generated-DYdwSB2I.js → sd.generated-Bwa7rses.js} +1 -1
- package/bundle/web-dist/client/assets/services.generated-BzwTuZRP.js +1 -0
- package/bundle/web-dist/client/assets/settings-B62_r1i4.js +1 -0
- package/bundle/web-dist/client/assets/{show-Bi0QYUVm.js → show-BCRrSc8-.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-CDiXKYGf.js +1 -0
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cx6BlvXM.js → use-oauth-login-flow-Qqg4koSg.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BaZaveNc.js → lazyRouteComponent-Ws7I6fL-.js} +2 -1
- package/bundle/web-dist/server/assets/{logs-DMOw3SjD.js → logs-C1rxLh8H.js} +4 -4
- package/bundle/web-dist/server/assets/{monit-UmcqHroX.js → monit-C8gx9Sar.js} +3 -2
- package/bundle/web-dist/server/assets/plus-BISXNqRU.js +19 -0
- package/bundle/web-dist/server/assets/react-dom-D7duWOwf.js +462 -0
- package/bundle/web-dist/server/assets/{routes-BfoQmYud.js → routes-ZW_Ih5Em.js} +11 -7
- package/bundle/web-dist/server/assets/{sd.generated-B0xqnHEo.js → sd.generated-LO-h1_16.js} +13 -461
- package/bundle/web-dist/server/assets/{services.generated-DqAKskAS.js → services.generated-CZIChgHV.js} +119 -2
- package/bundle/web-dist/server/assets/settings--aoatERH.js +382 -0
- package/bundle/web-dist/server/assets/{show-C-oSdzHX.js → show-BY27w9oi.js} +4 -4
- package/bundle/web-dist/server/assets/{tokens-Dkd_uXEk.js → tokens-DIObtTuZ.js} +17 -72
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-YM1nsd-H.js → use-oauth-login-flow-V3k8PSqQ.js} +2 -2
- package/bundle/web-dist/server/entry-server.generated.js +54 -18
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +17 -2
- package/skills/qgrid/references/openai-codex-runtime.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
- package/bundle/web-dist/client/assets/index-B4GlN1IX.css +0 -1
- package/bundle/web-dist/client/assets/index-CV_rp3zE.js +0 -78
- package/bundle/web-dist/client/assets/services.generated-BsAvotjG.js +0 -1
- package/bundle/web-dist/client/assets/tokens-BAuYgBl6.js +0 -1
- /package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-DzjfVtUr.js} +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 관리 가능한 런타임 설정 목록.
|
|
3
|
+
*
|
|
4
|
+
* 키·env 이름·타입·검증을 한 곳에 모아 API 와 UI 가 같은 정의를 본다. 여기 없는 env 는
|
|
5
|
+
* 화면에서 바꿀 수 없다 — DB 접속 정보나 포트처럼 서버가 뜨기 전에 필요한 값들이다.
|
|
6
|
+
*
|
|
7
|
+
* `settings` 테이블은 `(key, value)` 문자열 쌍만 담는 그릇이라(`setting.entity.json`),
|
|
8
|
+
* "이 키가 정수인지·범위가 얼마인지·화면에 뭐라고 쓸지"를 담을 곳이 따로 필요하다.
|
|
9
|
+
* 그 메타데이터가 여기 있다 — DB 스키마가 아니라 앱이 아는 상수 목록이다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type SettingKind = "boolean" | "integer" | "number" | "string" | "secret" | "preset";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 화면에서 묶어 보여줄 단위. 평평하게 나열하면 어디를 봐야 할지 알 수 없다.
|
|
16
|
+
*
|
|
17
|
+
* Slack 은 "언제 보낼까"(slack)와 "어디로 보낼까"(slackConnection)를 나눈다 — 앞은 운영
|
|
18
|
+
* 중 자주 만지고 뒤는 한 번 맞추면 끝이라 수명이 다르다.
|
|
19
|
+
*/
|
|
20
|
+
export type SettingGroup = "openai" | "slack" | "slackConnection";
|
|
21
|
+
|
|
22
|
+
export type SettingDef = {
|
|
23
|
+
key: string;
|
|
24
|
+
group: SettingGroup;
|
|
25
|
+
envKey: string;
|
|
26
|
+
label: string;
|
|
27
|
+
kind: SettingKind;
|
|
28
|
+
/** 적용 시점. 워커 설정은 dispatcher 생성자에서 한 번만 읽혀 재시작이 필요하다. */
|
|
29
|
+
applies: "immediate" | "restart";
|
|
30
|
+
min?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
/** `preset` 종류에서 고를 수 있는 값. 이 목록 밖의 값은 저장되지 않는다. */
|
|
33
|
+
presets?: number[];
|
|
34
|
+
/**
|
|
35
|
+
* 저장값도 env 도 없을 때 실제로 적용되는 값. 화면을 비워두면 "설정 안 됨"으로 읽히지만
|
|
36
|
+
* 실제로는 이 값이 돌고 있다 — 소비처의 기본값과 반드시 같아야 한다.
|
|
37
|
+
*/
|
|
38
|
+
fallback: string;
|
|
39
|
+
help?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 64 GiB 운영 호스트에서 세 자리 오입력을 막는 설정 상한. 안전을 보장하는 값은 아니다. */
|
|
43
|
+
export const MAX_OPENAI_ESTIMATED_RSS_GIB = 32;
|
|
44
|
+
export const MAX_OPENAI_MIN_HOST_AVAILABLE_GIB = 64;
|
|
45
|
+
|
|
46
|
+
export const SETTING_DEFS: SettingDef[] = [
|
|
47
|
+
{
|
|
48
|
+
key: "openai.autoscale",
|
|
49
|
+
fallback: "true",
|
|
50
|
+
group: "openai",
|
|
51
|
+
envKey: "QGRID_OPENAI_AUTOSCALE",
|
|
52
|
+
label: "OpenAI 워커 오토스케일",
|
|
53
|
+
kind: "boolean",
|
|
54
|
+
applies: "restart",
|
|
55
|
+
help: "끄면 워커 수가 최소값으로 고정됩니다",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: "openai.minWorkersPerToken",
|
|
59
|
+
fallback: "1",
|
|
60
|
+
group: "openai",
|
|
61
|
+
envKey: "QGRID_OPENAI_MIN_WORKERS_PER_TOKEN",
|
|
62
|
+
label: "토큰당 최소 워커",
|
|
63
|
+
kind: "integer",
|
|
64
|
+
applies: "restart",
|
|
65
|
+
min: 1,
|
|
66
|
+
max: 20,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "openai.maxWorkersPerToken",
|
|
70
|
+
fallback: "3",
|
|
71
|
+
group: "openai",
|
|
72
|
+
envKey: "QGRID_OPENAI_MAX_WORKERS_PER_TOKEN",
|
|
73
|
+
label: "토큰당 최대 워커",
|
|
74
|
+
kind: "integer",
|
|
75
|
+
applies: "restart",
|
|
76
|
+
min: 1,
|
|
77
|
+
max: 20,
|
|
78
|
+
help: "오토스케일이 꺼져 있으면 무시됩니다",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: "openai.maxEstimatedRssGiB",
|
|
82
|
+
fallback: "16",
|
|
83
|
+
group: "openai",
|
|
84
|
+
envKey: "QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB",
|
|
85
|
+
label: "워커 메모리 상한 (GiB)",
|
|
86
|
+
kind: "number",
|
|
87
|
+
applies: "restart",
|
|
88
|
+
min: 1,
|
|
89
|
+
max: MAX_OPENAI_ESTIMATED_RSS_GIB,
|
|
90
|
+
help: "이 추정치를 넘기면 워커를 더 늘리지 않습니다. 올릴수록 OOM 위험이 커지며 상한은 오입력 방지용입니다",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: "openai.minHostAvailableGiB",
|
|
94
|
+
fallback: "20",
|
|
95
|
+
group: "openai",
|
|
96
|
+
envKey: "QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB",
|
|
97
|
+
label: "호스트 여유 메모리 하한 (GiB)",
|
|
98
|
+
kind: "number",
|
|
99
|
+
applies: "restart",
|
|
100
|
+
min: 0,
|
|
101
|
+
max: MAX_OPENAI_MIN_HOST_AVAILABLE_GIB,
|
|
102
|
+
help: "호스트 여유가 이 값 아래면 스케일업을 멈춥니다. 확장을 끄려면 오토스케일을 끄세요",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
key: "slack.enabled",
|
|
106
|
+
fallback: "true",
|
|
107
|
+
group: "slack",
|
|
108
|
+
envKey: "SLACK_ENABLED",
|
|
109
|
+
label: "Slack 알림",
|
|
110
|
+
kind: "boolean",
|
|
111
|
+
applies: "immediate",
|
|
112
|
+
help: "연휴처럼 규칙으로 잡을 수 없는 기간에 끕니다. 서비스 정지급 장애 알림은 꺼도 전달됩니다",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: "slack.remindersEnabled",
|
|
116
|
+
fallback: "true",
|
|
117
|
+
group: "slack",
|
|
118
|
+
envKey: "SLACK_REMINDERS_ENABLED",
|
|
119
|
+
label: "만료 알림 반복",
|
|
120
|
+
kind: "boolean",
|
|
121
|
+
applies: "immediate",
|
|
122
|
+
help: "끄면 반복만 멈추고 만료 순간의 알림은 그대로 갑니다",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: "slack.expiryReminderMinutes",
|
|
126
|
+
fallback: "60",
|
|
127
|
+
group: "slack",
|
|
128
|
+
envKey: "SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES",
|
|
129
|
+
label: "반복 주기",
|
|
130
|
+
kind: "preset",
|
|
131
|
+
applies: "immediate",
|
|
132
|
+
// 자유 입력 대신 고정 선택지를 쓴다 — "얼마나 자주 재촉할까"라서 1분 단위 정밀도가
|
|
133
|
+
// 의미 없고, 잘못 넣으면 채널이 시끄러워진다. 끄는 것은 remindersEnabled 가 맡는다.
|
|
134
|
+
presets: [10, 30, 60, 180],
|
|
135
|
+
help: "재로그인이 필요한 토큰이 남아 있는 동안 이 주기로 다시 알립니다",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "slack.quietFromHour",
|
|
139
|
+
fallback: "20",
|
|
140
|
+
group: "slack",
|
|
141
|
+
envKey: "SLACK_QUIET_FROM_HOUR",
|
|
142
|
+
label: "조용 시간 시작",
|
|
143
|
+
kind: "integer",
|
|
144
|
+
applies: "immediate",
|
|
145
|
+
min: 0,
|
|
146
|
+
max: 23,
|
|
147
|
+
help: "이 시각부터 알리지 않습니다 (한국 시간)",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: "slack.quietUntilHour",
|
|
151
|
+
fallback: "8",
|
|
152
|
+
group: "slack",
|
|
153
|
+
envKey: "SLACK_QUIET_UNTIL_HOUR",
|
|
154
|
+
label: "조용 시간 종료",
|
|
155
|
+
kind: "integer",
|
|
156
|
+
applies: "immediate",
|
|
157
|
+
min: 0,
|
|
158
|
+
max: 23,
|
|
159
|
+
help: "이 시각부터 다시 알립니다. 시작보다 빠르면 자정을 넘는 구간이 됩니다",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: "slack.notifyOnWeekends",
|
|
163
|
+
fallback: "false",
|
|
164
|
+
group: "slack",
|
|
165
|
+
envKey: "SLACK_NOTIFY_ON_WEEKENDS",
|
|
166
|
+
label: "주말에도 알림",
|
|
167
|
+
kind: "boolean",
|
|
168
|
+
applies: "immediate",
|
|
169
|
+
help: "켜면 주말에도 위의 조용 시간 규칙만 적용됩니다",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
key: "slack.channelId",
|
|
173
|
+
fallback: "",
|
|
174
|
+
group: "slackConnection",
|
|
175
|
+
envKey: "SLACK_CHANNEL_ID",
|
|
176
|
+
label: "Slack 채널 ID",
|
|
177
|
+
kind: "string",
|
|
178
|
+
applies: "immediate",
|
|
179
|
+
help: "사용자 ID(U...)를 넣으면 그 사람 DM 으로 갑니다",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
key: "slack.userMap",
|
|
183
|
+
fallback: "",
|
|
184
|
+
group: "slackConnection",
|
|
185
|
+
envKey: "SLACK_USER_MAP",
|
|
186
|
+
label: "Slack 사용자 매핑",
|
|
187
|
+
kind: "string",
|
|
188
|
+
applies: "immediate",
|
|
189
|
+
help: "토큰명:SlackUserId 를 쉼표로. 공용 계정은 넣지 않습니다",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: "slack.botToken",
|
|
193
|
+
fallback: "",
|
|
194
|
+
group: "slackConnection",
|
|
195
|
+
envKey: "SLACK_BOT_TOKEN",
|
|
196
|
+
label: "Slack 봇 토큰",
|
|
197
|
+
kind: "secret",
|
|
198
|
+
applies: "immediate",
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
export function findSettingDef(key: string): SettingDef | undefined {
|
|
203
|
+
return SETTING_DEFS.find((d) => d.key === key);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 저장 전 검증. 잘못된 값이 들어가면 런타임에서 조용히 기본값으로 떨어져 원인을 찾기 어렵다. */
|
|
207
|
+
export function validateSettingValue(
|
|
208
|
+
def: SettingDef,
|
|
209
|
+
raw: string,
|
|
210
|
+
): { ok: true; value: string } | { ok: false; error: string } {
|
|
211
|
+
const trimmed = raw.trim();
|
|
212
|
+
|
|
213
|
+
if (def.kind === "boolean") {
|
|
214
|
+
if (!["true", "false"].includes(trimmed)) return { ok: false, error: "true 또는 false" };
|
|
215
|
+
return { ok: true, value: trimmed };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (def.kind === "preset") {
|
|
219
|
+
// 화면은 버튼만 주지만 API 는 아무 값이나 받을 수 있다. 목록 밖의 값이 들어오면
|
|
220
|
+
// 화면에서 아무 버튼도 선택돼 보이지 않아 "왜 안 바뀌지"가 된다.
|
|
221
|
+
const parsed = Number(trimmed);
|
|
222
|
+
if (!def.presets?.includes(parsed)) {
|
|
223
|
+
return { ok: false, error: `${def.presets?.join(", ") ?? ""} 중 하나여야 합니다` };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, value: String(parsed) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (def.kind === "integer" || def.kind === "number") {
|
|
229
|
+
const parsed = Number(trimmed);
|
|
230
|
+
if (!Number.isFinite(parsed)) return { ok: false, error: "숫자를 입력하세요" };
|
|
231
|
+
if (def.kind === "integer" && !Number.isInteger(parsed)) {
|
|
232
|
+
return { ok: false, error: "정수를 입력하세요" };
|
|
233
|
+
}
|
|
234
|
+
if (def.min !== undefined && parsed < def.min) {
|
|
235
|
+
return { ok: false, error: `${def.min} 이상이어야 합니다` };
|
|
236
|
+
}
|
|
237
|
+
if (def.max !== undefined && parsed > def.max) {
|
|
238
|
+
return { ok: false, error: `${def.max} 이하여야 합니다` };
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, value: String(parsed) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { ok: true, value: trimmed };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 화면에 그대로 내려보내면 안 되는 값. 앞뒤만 남긴다. */
|
|
247
|
+
export function maskSecret(value: string): string {
|
|
248
|
+
if (value.length <= 12) return "***";
|
|
249
|
+
return `${value.slice(0, 8)}...${value.slice(-4)}`;
|
|
250
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "Setting",
|
|
3
|
+
"table": "settings",
|
|
4
|
+
"title": "Setting",
|
|
5
|
+
"props": [
|
|
6
|
+
{
|
|
7
|
+
"name": "id",
|
|
8
|
+
"type": "integer",
|
|
9
|
+
"desc": "ID"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "created_at",
|
|
13
|
+
"desc": "등록일시",
|
|
14
|
+
"dbDefault": "CURRENT_TIMESTAMP",
|
|
15
|
+
"type": "date"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "key",
|
|
19
|
+
"type": "string",
|
|
20
|
+
"length": 100,
|
|
21
|
+
"desc": "설정 키 (예: openai.maxWorkersPerToken)",
|
|
22
|
+
"toFilter": true
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "value",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"desc": "설정 값 (문자열 저장, 앱에서 파싱·검증)"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "updated_at",
|
|
31
|
+
"type": "date",
|
|
32
|
+
"nullable": true,
|
|
33
|
+
"desc": "최종 수정일시"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"indexes": [
|
|
37
|
+
{
|
|
38
|
+
"type": "unique",
|
|
39
|
+
"name": "settings_key_unique",
|
|
40
|
+
"columns": [
|
|
41
|
+
{
|
|
42
|
+
"name": "key"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"using": "btree"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"subsets": {
|
|
49
|
+
"A": ["id", "created_at", "key", "value", "updated_at"]
|
|
50
|
+
},
|
|
51
|
+
"enums": {
|
|
52
|
+
"SettingOrderBy": {
|
|
53
|
+
"key-asc": "키순"
|
|
54
|
+
},
|
|
55
|
+
"SettingSearchField": {
|
|
56
|
+
"key": "키"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseModelClass,
|
|
3
|
+
type ListResult,
|
|
4
|
+
asArray,
|
|
5
|
+
NotFoundException,
|
|
6
|
+
BadRequestException,
|
|
7
|
+
api,
|
|
8
|
+
exhaustive,
|
|
9
|
+
} from "sonamu";
|
|
10
|
+
|
|
11
|
+
import { SD } from "../../i18n/sd.generated";
|
|
12
|
+
import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
|
|
13
|
+
import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
|
|
14
|
+
import { findSettingDef, maskSecret, SETTING_DEFS, validateSettingValue } from "./setting.constant";
|
|
15
|
+
import {
|
|
16
|
+
getSetting,
|
|
17
|
+
isStored,
|
|
18
|
+
resetSetting as resetStoredSetting,
|
|
19
|
+
saveSetting as saveStoredSetting,
|
|
20
|
+
} from "./setting.store";
|
|
21
|
+
import {
|
|
22
|
+
type RuntimeInfoItem,
|
|
23
|
+
type SettingApplies,
|
|
24
|
+
type SettingItem,
|
|
25
|
+
type SettingListParams,
|
|
26
|
+
type SettingSaveParams,
|
|
27
|
+
type SettingsResponse,
|
|
28
|
+
} from "./setting.types";
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
Setting Model
|
|
32
|
+
*/
|
|
33
|
+
class SettingModelClass extends BaseModelClass<
|
|
34
|
+
SettingSubsetKey,
|
|
35
|
+
SettingSubsetMapping,
|
|
36
|
+
typeof settingSubsetQueries,
|
|
37
|
+
typeof settingLoaderQueries
|
|
38
|
+
> {
|
|
39
|
+
constructor() {
|
|
40
|
+
super("Setting", settingSubsetQueries, settingLoaderQueries);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "Setting" })
|
|
44
|
+
async findById<T extends SettingSubsetKey>(
|
|
45
|
+
subset: T,
|
|
46
|
+
id: number,
|
|
47
|
+
): Promise<SettingSubsetMapping[T]> {
|
|
48
|
+
const { rows } = await this.findMany(subset, {
|
|
49
|
+
id,
|
|
50
|
+
num: 1,
|
|
51
|
+
page: 1,
|
|
52
|
+
});
|
|
53
|
+
if (!rows[0]) {
|
|
54
|
+
throw new NotFoundException(SD("error.entityNotFound")("Setting", id));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return rows[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async findOne<T extends SettingSubsetKey>(
|
|
61
|
+
subset: T,
|
|
62
|
+
listParams: SettingListParams,
|
|
63
|
+
): Promise<SettingSubsetMapping[T] | null> {
|
|
64
|
+
const { rows } = await this.findMany(subset, {
|
|
65
|
+
...listParams,
|
|
66
|
+
num: 1,
|
|
67
|
+
page: 1,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return rows[0] ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "Settings" })
|
|
74
|
+
async findMany<T extends SettingSubsetKey, LP extends SettingListParams>(
|
|
75
|
+
subset: T,
|
|
76
|
+
rawParams?: LP,
|
|
77
|
+
): Promise<ListResult<LP, SettingSubsetMapping[T]>> {
|
|
78
|
+
// params with defaults
|
|
79
|
+
const params = {
|
|
80
|
+
num: 24,
|
|
81
|
+
page: 1,
|
|
82
|
+
search: "key" as const,
|
|
83
|
+
orderBy: "key-asc" as const,
|
|
84
|
+
...rawParams,
|
|
85
|
+
} satisfies SettingListParams;
|
|
86
|
+
|
|
87
|
+
// build queries
|
|
88
|
+
const { qb, onSubset: _ } = this.getSubsetQueries(subset);
|
|
89
|
+
|
|
90
|
+
// id
|
|
91
|
+
if (params.id) {
|
|
92
|
+
qb.whereIn("settings.id", asArray(params.id));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// key — toFilter prop 이지만 스캐폴딩이 만들어주지 않아 직접 건다.
|
|
96
|
+
if (params.key) {
|
|
97
|
+
qb.where("settings.key", params.key);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// search-keyword
|
|
101
|
+
if (params.search && params.keyword && params.keyword.length > 0) {
|
|
102
|
+
if (params.search === "key") {
|
|
103
|
+
qb.where("settings.key", "like", `%${params.keyword}%`);
|
|
104
|
+
} else {
|
|
105
|
+
throw new BadRequestException(SD("error.unknownSearchField")(params.search));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// orderBy
|
|
110
|
+
if (params.orderBy) {
|
|
111
|
+
// default orderBy
|
|
112
|
+
if (params.orderBy === "key-asc") {
|
|
113
|
+
qb.orderBy("settings.key", "asc");
|
|
114
|
+
} else {
|
|
115
|
+
exhaustive(params.orderBy);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const enhancers = this.createEnhancers({
|
|
120
|
+
A: (row) => ({
|
|
121
|
+
...row,
|
|
122
|
+
// 서브셋별로 virtual 필드 계산로직 추가
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return this.executeSubsetQuery({
|
|
127
|
+
subset,
|
|
128
|
+
qb,
|
|
129
|
+
params,
|
|
130
|
+
enhancers,
|
|
131
|
+
debug: false,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async save(spa: SettingSaveParams[]): Promise<number[]> {
|
|
136
|
+
const wdb = this.getPuri("w");
|
|
137
|
+
|
|
138
|
+
// register
|
|
139
|
+
spa.forEach((sp) => {
|
|
140
|
+
wdb.ubRegister("settings", sp);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// transaction
|
|
144
|
+
return wdb.transaction(async (trx) => {
|
|
145
|
+
const ids = await trx.ubUpsert("settings");
|
|
146
|
+
|
|
147
|
+
return ids;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async del(ids: number[]): Promise<number> {
|
|
152
|
+
const wdb = this.getPuri("w");
|
|
153
|
+
|
|
154
|
+
// transaction
|
|
155
|
+
await wdb.transaction(async (trx) => {
|
|
156
|
+
return trx.table("settings").whereIn("settings.id", ids).delete();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return ids.length;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 저장된 설정 전체를 key→value 로. 부팅 시 한 번 읽어 메모리에 올린다. */
|
|
163
|
+
async findAllAsMap(): Promise<Map<string, string>> {
|
|
164
|
+
const { rows } = await this.findMany("A", { num: 0, page: 1 });
|
|
165
|
+
return new Map(rows.map((r) => [r.key, r.value]));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* key 로 upsert 한다. `save` 는 id 기준이라 key 중복을 걸러내지 못해, 기존 행을 먼저 찾아
|
|
170
|
+
* id 를 실어 보낸다.
|
|
171
|
+
*/
|
|
172
|
+
async setByKey(key: string, value: string): Promise<void> {
|
|
173
|
+
const existing = await this.findOne("A", { key, num: 1, page: 1 });
|
|
174
|
+
await this.save([
|
|
175
|
+
{
|
|
176
|
+
...(existing ? { id: existing.id } : {}),
|
|
177
|
+
key,
|
|
178
|
+
value,
|
|
179
|
+
updated_at: new Date(),
|
|
180
|
+
},
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 저장값을 지워 env 기본값으로 되돌린다. */
|
|
185
|
+
async clearByKey(key: string): Promise<void> {
|
|
186
|
+
const existing = await this.findOne("A", { key, num: 1, page: 1 });
|
|
187
|
+
if (existing) await this.del([existing.id]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 편집 가능한 설정과 읽기 전용 런타임 정보. 편집 대상은 `SETTING_DEFS` 에 정의된 것뿐이다 —
|
|
192
|
+
* DB 접속 정보나 포트는 서버가 뜨기 전에 필요해 화면에서 바꿀 수 없다.
|
|
193
|
+
*/
|
|
194
|
+
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "SettingList" })
|
|
195
|
+
async listSettings(): Promise<SettingsResponse> {
|
|
196
|
+
const settings: SettingItem[] = SETTING_DEFS.map((def) => {
|
|
197
|
+
// 저장값도 env 도 없으면 코드 기본값이 실제로 적용 중이다. 빈칸으로 두면 화면과
|
|
198
|
+
// 런타임이 어긋나 보인다.
|
|
199
|
+
const stored = isStored(def.key);
|
|
200
|
+
const raw = getSetting(def.key, def.envKey);
|
|
201
|
+
const effective = raw ?? def.fallback;
|
|
202
|
+
const source = stored ? "db" : raw !== undefined ? "env" : "default";
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
key: def.key,
|
|
206
|
+
group: def.group,
|
|
207
|
+
label: def.label,
|
|
208
|
+
kind: def.kind,
|
|
209
|
+
applies: def.applies,
|
|
210
|
+
// 눈 토글을 위해 secret 도 원본을 내려보낸다. 이 엔드포인트는 배포 경계에서 인증해야
|
|
211
|
+
// 하며, dev0 는 Caddy 인증을 통과한 운영자만 접근할 수 있다.
|
|
212
|
+
value: effective,
|
|
213
|
+
source,
|
|
214
|
+
min: def.min ?? null,
|
|
215
|
+
max: def.max ?? null,
|
|
216
|
+
presets: def.presets ?? [],
|
|
217
|
+
help: def.help ?? null,
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return { settings, runtime: this.runtimeInfo() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private runtimeInfo(): RuntimeInfoItem[] {
|
|
225
|
+
const env = process.env;
|
|
226
|
+
const password = env.QGRID_DB_PASSWORD ?? env.SONAMU_DB_PASSWORD ?? "";
|
|
227
|
+
return [
|
|
228
|
+
{ label: "NODE_ENV", value: env.NODE_ENV ?? "-" },
|
|
229
|
+
{ label: "HOST", value: env.HOST ?? "localhost" },
|
|
230
|
+
{ label: "PORT", value: env.PORT ?? "44900" },
|
|
231
|
+
{ label: "DB Host", value: env.QGRID_DB_HOST ?? env.SONAMU_DB_HOST ?? "-" },
|
|
232
|
+
{ label: "DB Port", value: env.QGRID_DB_PORT ?? env.SONAMU_DB_PORT ?? "-" },
|
|
233
|
+
{ label: "DB Name", value: env.QGRID_DB_NAME ?? env.SONAMU_DB_NAME ?? "-" },
|
|
234
|
+
{ label: "DB User", value: env.QGRID_DB_USER ?? env.SONAMU_DB_USER ?? "-" },
|
|
235
|
+
{ label: "DB Password", value: password ? maskSecret(password) : "-" },
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
240
|
+
async updateSetting(key: string, value: string): Promise<{ applies: SettingApplies }> {
|
|
241
|
+
const def = findSettingDef(key);
|
|
242
|
+
if (!def) throw new BadRequestException(SD("setting.unknownKey")(key));
|
|
243
|
+
|
|
244
|
+
const validation = validateSettingValue(def, value);
|
|
245
|
+
if (!validation.ok) {
|
|
246
|
+
throw new BadRequestException(SD("setting.invalidValue")(validation.error));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await saveStoredSetting(key, validation.value);
|
|
250
|
+
return { applies: def.applies };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 저장값을 지워 env 기본값으로 되돌린다. */
|
|
254
|
+
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
255
|
+
async resetSetting(key: string): Promise<{ applies: SettingApplies }> {
|
|
256
|
+
const def = findSettingDef(key);
|
|
257
|
+
if (!def) throw new BadRequestException(SD("setting.unknownKey")(key));
|
|
258
|
+
|
|
259
|
+
await resetStoredSetting(key);
|
|
260
|
+
return { applies: def.applies };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 만료 알림을 지금 한 번 보낸다. 주기를 기다리지 않고 설정이 제대로 붙었는지 확인하거나,
|
|
265
|
+
* 재로그인을 재촉할 때 쓴다. 보낸 건수를 돌려줘 "보낼 대상 없음"과 구분한다.
|
|
266
|
+
*/
|
|
267
|
+
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
268
|
+
async triggerExpiryReminder(): Promise<{ sent: number }> {
|
|
269
|
+
// 동적 import 로 알림 모듈을 호출 시점에만 끌어온다. 정적으로 걸면
|
|
270
|
+
// setting.model → expired-token-reminder → token.model 경로가 생겨,
|
|
271
|
+
// token.model 을 mock 하는 테스트가 실제 모듈을 먼저 로드하게 된다.
|
|
272
|
+
const { sendExpiredTokenReminderNow } = await import("../qgrid/expired-token-reminder");
|
|
273
|
+
return { sent: await sendExpiredTokenReminderNow() };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const SettingModel = new SettingModelClass();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getSetting,
|
|
5
|
+
isStored,
|
|
6
|
+
loadSettings,
|
|
7
|
+
resetSetting,
|
|
8
|
+
saveSetting,
|
|
9
|
+
setSettingChangeHandler,
|
|
10
|
+
} from "./setting.store";
|
|
11
|
+
|
|
12
|
+
const settingModelMock = {
|
|
13
|
+
clearByKey: vi.fn(),
|
|
14
|
+
findAllAsMap: vi.fn(),
|
|
15
|
+
setByKey: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("setting change handler", () => {
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
settingModelMock.clearByKey.mockReset();
|
|
21
|
+
settingModelMock.findAllAsMap.mockReset();
|
|
22
|
+
settingModelMock.setByKey.mockReset();
|
|
23
|
+
settingModelMock.clearByKey.mockResolvedValue(undefined);
|
|
24
|
+
settingModelMock.findAllAsMap.mockResolvedValue(new Map());
|
|
25
|
+
settingModelMock.setByKey.mockResolvedValue(undefined);
|
|
26
|
+
setSettingChangeHandler(null);
|
|
27
|
+
await loadSettings(settingModelMock);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("DB 저장과 캐시 갱신이 끝난 뒤 변경을 알린다", async () => {
|
|
31
|
+
const observed: Array<[string, string | undefined]> = [];
|
|
32
|
+
setSettingChangeHandler((key) => {
|
|
33
|
+
observed.push([key, getSetting(key, "TEST_SETTING")]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await saveSetting("slack.userMap", "yds:U-NEW", settingModelMock);
|
|
37
|
+
|
|
38
|
+
expect(settingModelMock.setByKey).toHaveBeenCalledWith("slack.userMap", "yds:U-NEW");
|
|
39
|
+
expect(observed).toEqual([["slack.userMap", "yds:U-NEW"]]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("reset 후 저장값을 지운 상태로 변경을 알린다", async () => {
|
|
43
|
+
settingModelMock.findAllAsMap.mockResolvedValueOnce(
|
|
44
|
+
new Map([["slack.expiryReminderMinutes", "30"]]),
|
|
45
|
+
);
|
|
46
|
+
await loadSettings(settingModelMock);
|
|
47
|
+
const observed: boolean[] = [];
|
|
48
|
+
setSettingChangeHandler((key) => observed.push(isStored(key)));
|
|
49
|
+
|
|
50
|
+
await resetSetting("slack.expiryReminderMinutes", settingModelMock);
|
|
51
|
+
|
|
52
|
+
expect(settingModelMock.clearByKey).toHaveBeenCalledWith("slack.expiryReminderMinutes");
|
|
53
|
+
expect(observed).toEqual([false]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("설정 로드는 변경 이벤트를 만들지 않는다", async () => {
|
|
57
|
+
const handler = vi.fn();
|
|
58
|
+
setSettingChangeHandler(handler);
|
|
59
|
+
settingModelMock.findAllAsMap.mockResolvedValueOnce(new Map([["slack.userMap", "yds:U1"]]));
|
|
60
|
+
|
|
61
|
+
await loadSettings(settingModelMock);
|
|
62
|
+
|
|
63
|
+
expect(handler).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handler 실패가 이미 성공한 저장을 실패로 바꾸지 않는다", async () => {
|
|
67
|
+
setSettingChangeHandler(() => {
|
|
68
|
+
throw new Error("reschedule failed");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await expect(saveSetting("slack.userMap", "yds:U1", settingModelMock)).resolves.toBeUndefined();
|
|
72
|
+
expect(getSetting("slack.userMap", "TEST_SETTING")).toBe("yds:U1");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("DB 저장이 실패하면 캐시와 handler를 건드리지 않는다", async () => {
|
|
76
|
+
const handler = vi.fn();
|
|
77
|
+
setSettingChangeHandler(handler);
|
|
78
|
+
settingModelMock.setByKey.mockRejectedValueOnce(new Error("db failed"));
|
|
79
|
+
|
|
80
|
+
await expect(saveSetting("slack.userMap", "yds:U1", settingModelMock)).rejects.toThrow(
|
|
81
|
+
"db failed",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(getSetting("slack.userMap", "TEST_SETTING")).toBeUndefined();
|
|
85
|
+
expect(handler).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|