create-entity-server 0.0.15 → 0.0.25
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/create.js +15 -7
- package/package.json +1 -1
- package/template/.env.example +8 -7
- package/template/configs/database.json +173 -10
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +28 -22
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/02_types_and_defaults.json +15 -16
- package/template/samples/entities/04_fk_and_composite_unique.json +0 -2
- package/template/samples/entities/05_cache.json +9 -8
- package/template/samples/entities/06_history_and_hard_delete.json +27 -9
- package/template/samples/entities/07_license_scope.json +40 -31
- package/template/samples/entities/09_hook_entity.json +0 -6
- package/template/samples/entities/10_hook_submit_delete.json +5 -2
- package/template/samples/entities/11_hook_webhook.json +9 -7
- package/template/samples/entities/12_hook_push.json +3 -3
- package/template/samples/entities/13_read_only.json +13 -10
- package/template/samples/entities/15_reset_defaults.json +0 -1
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +36 -39
- package/template/samples/flutter/lib/entity_server_client.dart +170 -48
- package/template/samples/java/EntityServerClient.java +208 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +175 -45
- package/template/samples/node/src/EntityServerClient.js +232 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +0 -1
- package/template/samples/php/ci4/Libraries/EntityServer.php +206 -53
- package/template/samples/php/laravel/Services/EntityServerService.php +190 -41
- package/template/samples/python/entity_server.py +181 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +143 -37
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +12 -8
- package/template/scripts/update-server.ps1 +68 -2
- package/template/scripts/update-server.sh +59 -2
- package/template/samples/entities/order_notification.json +0 -51
- package/template/samples/react/src/api/entityServerClient.ts +0 -413
- package/template/samples/react/src/hooks/useEntity.ts +0 -173
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Entity Server — 브라우저 예제</title>
|
|
8
|
+
<style>
|
|
9
|
+
*,
|
|
10
|
+
*::before,
|
|
11
|
+
*::after {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
17
|
+
background: #0f1117;
|
|
18
|
+
color: #e2e8f0;
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 1.5rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
h1 {
|
|
24
|
+
font-size: 1.4rem;
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
margin: 0 0 1.5rem;
|
|
27
|
+
color: #f8fafc;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
h2 {
|
|
31
|
+
font-size: 1rem;
|
|
32
|
+
font-weight: 600;
|
|
33
|
+
color: #94a3b8;
|
|
34
|
+
margin: 0 0 .75rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.grid {
|
|
38
|
+
display: grid;
|
|
39
|
+
grid-template-columns: 300px 1fr;
|
|
40
|
+
gap: 1rem;
|
|
41
|
+
max-width: 1000px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.card {
|
|
45
|
+
background: #1e2330;
|
|
46
|
+
border: 1px solid #2d3446;
|
|
47
|
+
border-radius: 10px;
|
|
48
|
+
padding: 1.25rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
label {
|
|
52
|
+
display: block;
|
|
53
|
+
font-size: .8rem;
|
|
54
|
+
color: #94a3b8;
|
|
55
|
+
margin-bottom: 3px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
input,
|
|
59
|
+
textarea,
|
|
60
|
+
select {
|
|
61
|
+
width: 100%;
|
|
62
|
+
background: #0f1117;
|
|
63
|
+
border: 1px solid #2d3446;
|
|
64
|
+
border-radius: 6px;
|
|
65
|
+
color: #e2e8f0;
|
|
66
|
+
padding: .45rem .65rem;
|
|
67
|
+
font-size: .85rem;
|
|
68
|
+
margin-bottom: .75rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
input:focus,
|
|
72
|
+
textarea:focus {
|
|
73
|
+
outline: 1px solid #4f8ef7;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
textarea {
|
|
77
|
+
height: 90px;
|
|
78
|
+
resize: vertical;
|
|
79
|
+
font-family: monospace;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
button {
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: .4rem;
|
|
86
|
+
background: #4f8ef7;
|
|
87
|
+
color: #fff;
|
|
88
|
+
border: none;
|
|
89
|
+
border-radius: 6px;
|
|
90
|
+
padding: .5rem 1rem;
|
|
91
|
+
font-size: .85rem;
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
transition: background .15s;
|
|
94
|
+
margin: .2rem .2rem .2rem 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
button:hover {
|
|
98
|
+
background: #3a7be3;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
button.danger {
|
|
102
|
+
background: #e05252;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
button.danger:hover {
|
|
106
|
+
background: #c43e3e;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
button.neutral {
|
|
110
|
+
background: #374058;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
button.neutral:hover {
|
|
114
|
+
background: #4a5470;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#log {
|
|
118
|
+
background: #090c12;
|
|
119
|
+
border: 1px solid #2d3446;
|
|
120
|
+
border-radius: 10px;
|
|
121
|
+
padding: 1rem;
|
|
122
|
+
font-family: 'Cascadia Code', 'Fira Mono', monospace;
|
|
123
|
+
font-size: .78rem;
|
|
124
|
+
overflow-y: auto;
|
|
125
|
+
height: 520px;
|
|
126
|
+
white-space: pre-wrap;
|
|
127
|
+
word-break: break-all;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.log-ok {
|
|
131
|
+
color: #4ade80;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.log-err {
|
|
135
|
+
color: #f87171;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.log-info {
|
|
139
|
+
color: #7dd3fc;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.log-dim {
|
|
143
|
+
color: #475569;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.divider {
|
|
147
|
+
border: none;
|
|
148
|
+
border-top: 1px solid #2d3446;
|
|
149
|
+
margin: .75rem 0;
|
|
150
|
+
}
|
|
151
|
+
</style>
|
|
152
|
+
</head>
|
|
153
|
+
|
|
154
|
+
<body>
|
|
155
|
+
|
|
156
|
+
<h1>🗂 Entity Server — 브라우저 예제</h1>
|
|
157
|
+
|
|
158
|
+
<div class="grid">
|
|
159
|
+
<!-- ── 왼쪽: 설정 + 조작 ── -->
|
|
160
|
+
<div style="display:flex;flex-direction:column;gap:1rem;">
|
|
161
|
+
|
|
162
|
+
<!-- 연결 설정 -->
|
|
163
|
+
<div class="card">
|
|
164
|
+
<h2>연결 설정</h2>
|
|
165
|
+
|
|
166
|
+
<label>서버 URL</label>
|
|
167
|
+
<input id="url" value="http://localhost:47200" />
|
|
168
|
+
|
|
169
|
+
<label>인증 모드</label>
|
|
170
|
+
<select id="authMode" onchange="onAuthModeChange()">
|
|
171
|
+
<option value="token">JWT Token</option>
|
|
172
|
+
<option value="hmac">HMAC (API Key + Secret)</option>
|
|
173
|
+
</select>
|
|
174
|
+
|
|
175
|
+
<div id="tokenSection">
|
|
176
|
+
<label>JWT Token</label>
|
|
177
|
+
<input id="token" type="password" placeholder="Bearer 토큰" />
|
|
178
|
+
</div>
|
|
179
|
+
<div id="hmacSection" style="display:none">
|
|
180
|
+
<label>API Key</label>
|
|
181
|
+
<input id="apiKey" placeholder="X-API-Key" />
|
|
182
|
+
<label>HMAC Secret</label>
|
|
183
|
+
<input id="hmacSecret" type="password" placeholder="서명 시크릿" />
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<button onclick="initClient()">🔌 연결</button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<!-- CRUD -->
|
|
190
|
+
<div class="card">
|
|
191
|
+
<h2>CRUD</h2>
|
|
192
|
+
|
|
193
|
+
<label>엔티티명</label>
|
|
194
|
+
<input id="entity" value="product" />
|
|
195
|
+
|
|
196
|
+
<label>데이터 (JSON)</label>
|
|
197
|
+
<textarea id="data">{"name":"무선 키보드","price":89000,"category":"peripherals"}</textarea>
|
|
198
|
+
|
|
199
|
+
<div style="display:flex;gap:.3rem;flex-wrap:wrap;">
|
|
200
|
+
<button onclick="doList()">📋 목록</button>
|
|
201
|
+
<button onclick="doSubmit()">💾 저장</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<hr class="divider" />
|
|
205
|
+
|
|
206
|
+
<label>seq (단건 조회/삭제)</label>
|
|
207
|
+
<input id="seq" type="number" placeholder="seq" />
|
|
208
|
+
|
|
209
|
+
<div style="display:flex;gap:.3rem;flex-wrap:wrap;">
|
|
210
|
+
<button onclick="doGet()" class="neutral">🔍 조회</button>
|
|
211
|
+
<button onclick="doHistory()" class="neutral">📜 이력</button>
|
|
212
|
+
<button onclick="doDelete()" class="danger">🗑 삭제</button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<!-- 트랜잭션 -->
|
|
217
|
+
<div class="card">
|
|
218
|
+
<h2>트랜잭션</h2>
|
|
219
|
+
<div style="display:flex;gap:.3rem;flex-wrap:wrap;">
|
|
220
|
+
<button onclick="doTxStart()" class="neutral">▶ 시작</button>
|
|
221
|
+
<button onclick="doTxCommit()">✔ 커밋</button>
|
|
222
|
+
<button onclick="doTxRollback()" class="danger">✖ 롤백</button>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<!-- 웹 푸시 -->
|
|
227
|
+
<div class="card">
|
|
228
|
+
<h2>Web Push 등록</h2>
|
|
229
|
+
<label>VAPID Public Key</label>
|
|
230
|
+
<input id="vapidKey" placeholder="서버 VAPID public key (Base64Url)" />
|
|
231
|
+
<label>Account Seq</label>
|
|
232
|
+
<input id="pushAccountSeq" type="number" placeholder="로그인한 account_seq" />
|
|
233
|
+
<button onclick="doSubscribePush()">🔔 푸시 구독 등록</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<!-- ── 오른쪽: 로그 ── -->
|
|
238
|
+
<div style="display:flex;flex-direction:column;gap:.5rem;">
|
|
239
|
+
<div style="display:flex;justify-content:space-between;align-items:center;">
|
|
240
|
+
<h2 style="margin:0">응답 로그</h2>
|
|
241
|
+
<button onclick="clearLog()" class="neutral" style="margin:0;padding:.3rem .75rem;font-size:.75rem;">지우기</button>
|
|
242
|
+
</div>
|
|
243
|
+
<pre id="log"><span class="log-dim">// 연결 후 조작하면 여기에 응답이 표시됩니다.</span></pre>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- ── 로직 ─────────────────────────────────────────────── -->
|
|
248
|
+
<script type="module">
|
|
249
|
+
/**
|
|
250
|
+
* 이 예제는 entity-server-client.js를 직접 import하여 사용합니다.
|
|
251
|
+
* CORS가 허용된 환경이거나 같은 origin에서 실행해야 합니다.
|
|
252
|
+
*
|
|
253
|
+
* 파일을 로컬에서 열 때는 Live Server(VS Code) 등 HTTP 서버가 필요합니다.
|
|
254
|
+
* (file:// 프로토콜은 ES Module import가 동작하지 않습니다)
|
|
255
|
+
*/
|
|
256
|
+
import {
|
|
257
|
+
EntityServerClient
|
|
258
|
+
} from "./entity-server-client.js";
|
|
259
|
+
|
|
260
|
+
let es = null;
|
|
261
|
+
|
|
262
|
+
// ── 유틸 ────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
function log(label, data, isError = false) {
|
|
265
|
+
const el = document.getElementById("log");
|
|
266
|
+
const ts = new Date().toLocaleTimeString();
|
|
267
|
+
const cls = isError ? "log-err" : "log-ok";
|
|
268
|
+
const json = typeof data === "object" ? JSON.stringify(data, null, 2) : String(data);
|
|
269
|
+
el.innerHTML +=
|
|
270
|
+
`\n<span class="log-dim">[${ts}]</span> <span class="log-info">${label}</span>\n` +
|
|
271
|
+
`<span class="${cls}">${json}</span>\n`;
|
|
272
|
+
el.scrollTop = el.scrollHeight;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function getEntity() {
|
|
276
|
+
return document.getElementById("entity").value.trim();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getSeq() {
|
|
280
|
+
return Number(document.getElementById("seq").value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getData() {
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(document.getElementById("data").value);
|
|
286
|
+
} catch {
|
|
287
|
+
throw new Error("데이터 JSON 파싱 오류");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
window.clearLog = () => {
|
|
292
|
+
document.getElementById("log").innerHTML = '<span class="log-dim">// 지워졌습니다.</span>';
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
window.onAuthModeChange = () => {
|
|
296
|
+
const mode = document.getElementById("authMode").value;
|
|
297
|
+
document.getElementById("tokenSection").style.display = mode === "token" ? "block" : "none";
|
|
298
|
+
document.getElementById("hmacSection").style.display = mode === "hmac" ? "block" : "none";
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// ── 연결 ────────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
window.initClient = async () => {
|
|
304
|
+
const mode = document.getElementById("authMode").value;
|
|
305
|
+
const opts = {
|
|
306
|
+
baseUrl: document.getElementById("url").value.trim()
|
|
307
|
+
};
|
|
308
|
+
if (mode === "token") {
|
|
309
|
+
opts.token = document.getElementById("token").value.trim();
|
|
310
|
+
} else {
|
|
311
|
+
opts.apiKey = document.getElementById("apiKey").value.trim();
|
|
312
|
+
opts.hmacSecret = document.getElementById("hmacSecret").value.trim();
|
|
313
|
+
}
|
|
314
|
+
es = new EntityServerClient(opts);
|
|
315
|
+
try {
|
|
316
|
+
const health = await es.checkHealth();
|
|
317
|
+
log("checkHealth()", health);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
log("checkHealth() 오류", e.message, true);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
window.doList = async () => {
|
|
326
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
327
|
+
try {
|
|
328
|
+
const res = await es.list(getEntity(), {
|
|
329
|
+
page: 1,
|
|
330
|
+
limit: 10,
|
|
331
|
+
fields: ["*"]
|
|
332
|
+
});
|
|
333
|
+
log(`list("${getEntity()}")`, res);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
log("list() 오류", e.message, true);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
window.doSubmit = async () => {
|
|
340
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
341
|
+
try {
|
|
342
|
+
const res = await es.submit(getEntity(), getData());
|
|
343
|
+
log(`submit("${getEntity()}")`, res);
|
|
344
|
+
if (res.seq) document.getElementById("seq").value = res.seq;
|
|
345
|
+
} catch (e) {
|
|
346
|
+
log("submit() 오류", e.message, true);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
window.doGet = async () => {
|
|
351
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
352
|
+
const seq = getSeq();
|
|
353
|
+
if (!seq) return log("오류", "seq를 입력하세요.", true);
|
|
354
|
+
try {
|
|
355
|
+
const res = await es.get(getEntity(), seq);
|
|
356
|
+
log(`get("${getEntity()}", ${seq})`, res);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
log("get() 오류", e.message, true);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
window.doHistory = async () => {
|
|
363
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
364
|
+
const seq = getSeq();
|
|
365
|
+
if (!seq) return log("오류", "seq를 입력하세요.", true);
|
|
366
|
+
try {
|
|
367
|
+
const res = await es.history(getEntity(), seq);
|
|
368
|
+
log(`history("${getEntity()}", ${seq})`, res);
|
|
369
|
+
} catch (e) {
|
|
370
|
+
log("history() 오류", e.message, true);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
window.doDelete = async () => {
|
|
375
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
376
|
+
const seq = getSeq();
|
|
377
|
+
if (!seq) return log("오류", "seq를 입력하세요.", true);
|
|
378
|
+
if (!confirm(`정말 삭제할까요? seq=${seq}`)) return;
|
|
379
|
+
try {
|
|
380
|
+
const res = await es.delete(getEntity(), seq);
|
|
381
|
+
log(`delete("${getEntity()}", ${seq})`, res);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
log("delete() 오류", e.message, true);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// ── 트랜잭션 ─────────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
window.doTxStart = async () => {
|
|
390
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
391
|
+
try {
|
|
392
|
+
const txId = await es.transStart();
|
|
393
|
+
log("transStart()", {
|
|
394
|
+
transaction_id: txId
|
|
395
|
+
});
|
|
396
|
+
} catch (e) {
|
|
397
|
+
log("transStart() 오류", e.message, true);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
window.doTxCommit = async () => {
|
|
402
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
403
|
+
try {
|
|
404
|
+
const res = await es.transCommit();
|
|
405
|
+
log("transCommit()", res);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
log("transCommit() 오류", e.message, true);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
window.doTxRollback = async () => {
|
|
412
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
413
|
+
try {
|
|
414
|
+
const res = await es.transRollback();
|
|
415
|
+
log("transRollback()", res);
|
|
416
|
+
} catch (e) {
|
|
417
|
+
log("transRollback() 오류", e.message, true);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// ── Web Push ─────────────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 브라우저 Web Push 구독을 얻어 account_device에 등록합니다.
|
|
425
|
+
*
|
|
426
|
+
* 서버에서 VAPID 키 쌍을 사전에 생성해 두어야 합니다:
|
|
427
|
+
* web-push generate-vapid-keys
|
|
428
|
+
*
|
|
429
|
+
* push_config.vapid_public_key 값을 VAPID Public Key 입력란에 붙여넣으세요.
|
|
430
|
+
*/
|
|
431
|
+
window.doSubscribePush = async () => {
|
|
432
|
+
if (!es) return log("오류", "먼저 연결하세요.", true);
|
|
433
|
+
|
|
434
|
+
const vapidKey = document.getElementById("vapidKey").value.trim();
|
|
435
|
+
const accountSeq = Number(document.getElementById("pushAccountSeq").value);
|
|
436
|
+
if (!vapidKey) return log("오류", "VAPID Public Key를 입력하세요.", true);
|
|
437
|
+
if (!accountSeq) return log("오류", "Account Seq를 입력하세요.", true);
|
|
438
|
+
|
|
439
|
+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
440
|
+
return log("오류", "이 브라우저는 Web Push를 지원하지 않습니다.", true);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
// Service Worker 등록 (최소 SW: push 이벤트 수신용)
|
|
445
|
+
// 실제 서비스에서는 별도 sw.js 파일을 사용하세요.
|
|
446
|
+
const swBlob = new Blob([`
|
|
447
|
+
self.addEventListener('push', e => {
|
|
448
|
+
const d = e.data?.json() ?? {};
|
|
449
|
+
self.registration.showNotification(d.title ?? '알림', {
|
|
450
|
+
body: d.body ?? '',
|
|
451
|
+
data: d.data ?? {},
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
`], {
|
|
455
|
+
type: "application/javascript"
|
|
456
|
+
});
|
|
457
|
+
const swUrl = URL.createObjectURL(swBlob);
|
|
458
|
+
const reg = await navigator.serviceWorker.register(swUrl, {
|
|
459
|
+
scope: "/"
|
|
460
|
+
});
|
|
461
|
+
await navigator.serviceWorker.ready;
|
|
462
|
+
|
|
463
|
+
// 푸시 구독
|
|
464
|
+
const perm = await Notification.requestPermission();
|
|
465
|
+
if (perm !== "granted") return log("푸시 권한 거부됨", perm, true);
|
|
466
|
+
|
|
467
|
+
const subscription = await reg.pushManager.subscribe({
|
|
468
|
+
userVisibleOnly: true,
|
|
469
|
+
applicationServerKey: vapidKey,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// device_id: 브라우저 별 고정 ID (localStorage에 보관)
|
|
473
|
+
let deviceId = localStorage.getItem("push_device_id");
|
|
474
|
+
if (!deviceId) {
|
|
475
|
+
deviceId = crypto.randomUUID();
|
|
476
|
+
localStorage.setItem("push_device_id", deviceId);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const browser = navigator.userAgentData?.brands?.[0]?.brand ??
|
|
480
|
+
navigator.userAgent.split(")")[0].split("(")[1];
|
|
481
|
+
|
|
482
|
+
const res = await es.registerWebPushDevice(
|
|
483
|
+
accountSeq,
|
|
484
|
+
deviceId,
|
|
485
|
+
JSON.stringify(subscription), {
|
|
486
|
+
browser
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
log("registerWebPushDevice()", res);
|
|
490
|
+
} catch (e) {
|
|
491
|
+
log("push 등록 오류", e.message, true);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
</script>
|
|
495
|
+
|
|
496
|
+
</body>
|
|
497
|
+
|
|
498
|
+
</html>
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"type": "decimal"
|
|
18
18
|
},
|
|
19
19
|
"stock_qty": {
|
|
20
|
-
"comment": "재고 수량 (*_qty → INT 자동 추론)"
|
|
20
|
+
"comment": "재고 수량 (*_qty → INT 자동 추론)",
|
|
21
|
+
"default": 0
|
|
21
22
|
},
|
|
22
23
|
"weight": {
|
|
23
24
|
"comment": "무게(kg) — 자동추론 없음, types에서 decimal 선언",
|
|
@@ -31,21 +32,19 @@
|
|
|
31
32
|
"comment": "출시일시 (*_at → DATETIME 자동 추론)"
|
|
32
33
|
}
|
|
33
34
|
},
|
|
34
|
-
"
|
|
35
|
-
"description":
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"spec_json":
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"is_available": true,
|
|
48
|
-
"stock_qty": 0
|
|
35
|
+
"fields": {
|
|
36
|
+
"description": {
|
|
37
|
+
"type": "text",
|
|
38
|
+
"comment": "상품 상세 설명"
|
|
39
|
+
},
|
|
40
|
+
"thumbnail_url": {
|
|
41
|
+
"type": "varchar(500)",
|
|
42
|
+
"comment": "썸네일 이미지 URL"
|
|
43
|
+
},
|
|
44
|
+
"spec_json": {
|
|
45
|
+
"type": "json",
|
|
46
|
+
"comment": "상품 스펙 (JSON 객체)"
|
|
47
|
+
}
|
|
49
48
|
},
|
|
50
49
|
"reset_defaults": [
|
|
51
50
|
{
|
|
@@ -16,18 +16,19 @@
|
|
|
16
16
|
"comment": "공개 여부 (is_* → TINYINT(1) 자동 추론)"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
|
-
"types": {
|
|
20
|
-
"value": "text",
|
|
21
|
-
"description": "text"
|
|
22
|
-
},
|
|
23
|
-
"comments": {
|
|
24
|
-
"value": "설정 값 (JSON 또는 문자열)",
|
|
25
|
-
"description": "설정 설명"
|
|
26
|
-
},
|
|
27
19
|
"cache": {
|
|
28
20
|
"enabled": true,
|
|
29
21
|
"ttl_seconds": 600
|
|
30
22
|
},
|
|
23
|
+
"fields": {
|
|
24
|
+
"value": {
|
|
25
|
+
"comment": "설정 값 (문자열 저장, 타입은 애플리케이션에서 해석)",
|
|
26
|
+
"required": true
|
|
27
|
+
},
|
|
28
|
+
"description": {
|
|
29
|
+
"comment": "설정 항목 설명"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
31
32
|
"reset_defaults": [
|
|
32
33
|
{
|
|
33
34
|
"key": "site.name",
|
|
@@ -27,15 +27,33 @@
|
|
|
27
27
|
"comment": "조회 수 (*_count → INT 자동 추론)"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
|
-
"
|
|
31
|
-
"content":
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
"fields": {
|
|
31
|
+
"content": {
|
|
32
|
+
"type": "longtext",
|
|
33
|
+
"comment": "게시글 본문",
|
|
34
|
+
"required": true
|
|
35
|
+
},
|
|
36
|
+
"summary": {
|
|
37
|
+
"type": "text",
|
|
38
|
+
"comment": "요약 (없으면 본문 앞 200자 자동 생성)"
|
|
39
|
+
},
|
|
40
|
+
"meta": {
|
|
41
|
+
"comment": "SEO / 부가 메타데이터 그룹 (중첩 fields 예제)",
|
|
42
|
+
"fields": {
|
|
43
|
+
"og_title": {
|
|
44
|
+
"type": "varchar(200)",
|
|
45
|
+
"comment": "OG 제목 (미입력 시 title 사용)"
|
|
46
|
+
},
|
|
47
|
+
"og_image_url": {
|
|
48
|
+
"type": "varchar(500)",
|
|
49
|
+
"comment": "OG 이미지 URL"
|
|
50
|
+
},
|
|
51
|
+
"tags_json": {
|
|
52
|
+
"type": "json",
|
|
53
|
+
"comment": "태그 목록 (JSON 배열)"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
39
57
|
},
|
|
40
58
|
"history_ttl": 1095,
|
|
41
59
|
"hard_delete": false
|