create-entity-server 0.0.9 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/bin/create.js +26 -8
  2. package/package.json +1 -1
  3. package/template/.env.example +20 -3
  4. package/template/configs/database.json +173 -10
  5. package/template/configs/jwt.json +1 -0
  6. package/template/configs/oauth.json +37 -0
  7. package/template/configs/push.json +26 -0
  8. package/template/entities/Account/account_audit.json +4 -5
  9. package/template/entities/README.md +4 -4
  10. package/template/entities/{Auth → System/Auth}/account.json +0 -14
  11. package/template/entities/System/system_audit_log.json +14 -8
  12. package/template/samples/README.md +43 -21
  13. package/template/samples/browser/entity-server-client.js +453 -0
  14. package/template/samples/browser/example.html +498 -0
  15. package/template/samples/entities/01_basic_fields.json +39 -0
  16. package/template/samples/entities/02_types_and_defaults.json +67 -0
  17. package/template/samples/entities/03_hash_and_unique.json +33 -0
  18. package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
  19. package/template/samples/entities/05_cache.json +55 -0
  20. package/template/samples/entities/06_history_and_hard_delete.json +60 -0
  21. package/template/samples/entities/07_license_scope.json +52 -0
  22. package/template/samples/entities/08_hook_sql.json +52 -0
  23. package/template/samples/entities/09_hook_entity.json +65 -0
  24. package/template/samples/entities/10_hook_submit_delete.json +78 -0
  25. package/template/samples/entities/11_hook_webhook.json +84 -0
  26. package/template/samples/entities/12_hook_push.json +73 -0
  27. package/template/samples/entities/13_read_only.json +54 -0
  28. package/template/samples/entities/14_optimistic_lock.json +29 -0
  29. package/template/samples/entities/15_reset_defaults.json +94 -0
  30. package/template/samples/entities/16_isolated_license.json +62 -0
  31. package/template/samples/entities/README.md +91 -0
  32. package/template/samples/flutter/lib/entity_server_client.dart +261 -48
  33. package/template/samples/java/EntityServerClient.java +325 -61
  34. package/template/samples/java/EntityServerExample.java +4 -3
  35. package/template/samples/kotlin/EntityServerClient.kt +261 -45
  36. package/template/samples/node/src/EntityServerClient.js +348 -59
  37. package/template/samples/node/src/example.js +9 -9
  38. package/template/samples/php/ci4/Config/EntityServer.php +14 -0
  39. package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
  40. package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
  41. package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
  42. package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
  43. package/template/samples/python/entity_server.py +287 -68
  44. package/template/samples/python/example.py +7 -6
  45. package/template/samples/react/src/example.tsx +41 -25
  46. package/template/samples/swift/EntityServerClient.swift +248 -37
  47. package/template/scripts/normalize-entities.sh +10 -10
  48. package/template/scripts/run.ps1 +12 -3
  49. package/template/scripts/run.sh +120 -37
  50. package/template/scripts/update-server.ps1 +160 -4
  51. package/template/scripts/update-server.sh +132 -4
  52. package/template/samples/react/src/api/entityServerClient.ts +0 -290
  53. package/template/samples/react/src/hooks/useEntity.ts +0 -105
  54. /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
  55. /package/template/entities/{Auth → System/Auth}/license.json +0 -0
  56. /package/template/entities/{Auth → System/Auth}/rbac_roles.json +0 -0
@@ -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>
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "contact",
3
+ "description": "기본 필드 타입 예제 — 인덱스 필드 자동 추론 패턴 모음",
4
+ "index": {
5
+ "name": {
6
+ "comment": "이름 (*_name → VARCHAR(100) 자동 추론)"
7
+ },
8
+ "email": {
9
+ "comment": "이메일 (*email* → VARCHAR(255) 자동 추론)",
10
+ "type": "email"
11
+ },
12
+ "phone": {
13
+ "comment": "전화번호 (*phone* → VARCHAR(50) 자동 추론)"
14
+ },
15
+ "birth_date": {
16
+ "comment": "생년월일 (*_date → DATE 자동 추론)"
17
+ },
18
+ "joined_at": {
19
+ "comment": "가입일시 (*_at → DATETIME 자동 추론)"
20
+ },
21
+ "visit_count": {
22
+ "comment": "방문 횟수 (*_count → INT 자동 추론)"
23
+ },
24
+ "total_amount": {
25
+ "comment": "총 금액 (*_amount → DECIMAL(15,2) 자동 추론)"
26
+ },
27
+ "is_verified": {
28
+ "comment": "인증 여부 (is_* → TINYINT(1) 자동 추론)"
29
+ },
30
+ "user_seq": {
31
+ "comment": "연결된 사용자 seq (*_seq → BIGINT UNSIGNED 자동 추론)"
32
+ },
33
+ "status": {
34
+ "comment": "상태 (enum 배열로 허용값 제한)",
35
+ "type": ["active", "inactive"],
36
+ "default": "active"
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "product",
3
+ "description": "명시적 타입 선언 & defaults 예제 — 자동 추론이 안 되는 필드에 types 사용",
4
+ "index": {
5
+ "sku": {
6
+ "comment": "상품 코드",
7
+ "required": true,
8
+ "unique": true
9
+ },
10
+ "category": {
11
+ "comment": "카테고리",
12
+ "type": ["electronics", "furniture", "clothing", "food"],
13
+ "default": "electronics"
14
+ },
15
+ "price": {
16
+ "comment": "정가 (decimal — 자동추론 불가, 명시 선언)",
17
+ "type": "decimal"
18
+ },
19
+ "stock_qty": {
20
+ "comment": "재고 수량 (*_qty → INT 자동 추론)",
21
+ "default": 0
22
+ },
23
+ "weight": {
24
+ "comment": "무게(kg) — 자동추론 없음, types에서 decimal 선언",
25
+ "type": "decimal"
26
+ },
27
+ "is_available": {
28
+ "comment": "판매 가능 여부 (is_* → TINYINT(1) 자동 추론)",
29
+ "default": true
30
+ },
31
+ "launched_at": {
32
+ "comment": "출시일시 (*_at → DATETIME 자동 추론)"
33
+ }
34
+ },
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
+ }
48
+ },
49
+ "reset_defaults": [
50
+ {
51
+ "sku": "ELEC-001",
52
+ "category": "electronics",
53
+ "price": 99000,
54
+ "stock_qty": 100,
55
+ "is_available": true,
56
+ "description": "샘플 전자제품"
57
+ },
58
+ {
59
+ "sku": "FURN-001",
60
+ "category": "furniture",
61
+ "price": 299000,
62
+ "stock_qty": 20,
63
+ "is_available": true,
64
+ "description": "샘플 가구"
65
+ }
66
+ ]
67
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "member",
3
+ "description": "hash & unique 예제 — 민감 필드 해시 저장 + 단일/복합 유니크",
4
+ "index": {
5
+ "email": {
6
+ "comment": "이메일 (고유 — 중복 불가)",
7
+ "type": "email",
8
+ "required": true,
9
+ "unique": true
10
+ },
11
+ "phone": {
12
+ "comment": "전화번호 (해시 저장 — 평문 노출 차단)",
13
+ "hash": true
14
+ },
15
+ "resident_id": {
16
+ "comment": "주민등록번호 앞 7자리 (해시 + 유니크 — 중복 가입 방지)",
17
+ "hash": true,
18
+ "unique": true
19
+ },
20
+ "nickname": {
21
+ "comment": "닉네임 (unique — 중복 불가)"
22
+ },
23
+ "org_seq": {
24
+ "comment": "소속 조직 seq"
25
+ },
26
+ "status": {
27
+ "comment": "상태",
28
+ "type": ["active", "inactive", "withdrawn"],
29
+ "default": "active"
30
+ }
31
+ },
32
+ "unique": [["org_seq", "nickname"]]
33
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "team_member",
3
+ "description": "외래키(fk) & 복합 유니크 예제 — 팀-사용자 다대다 중간 테이블",
4
+ "index": {
5
+ "team_seq": {
6
+ "comment": "팀 seq (fk: team.seq)",
7
+ "required": true
8
+ },
9
+ "user_seq": {
10
+ "comment": "사용자 seq (fk: user.seq)",
11
+ "required": true
12
+ },
13
+ "role": {
14
+ "comment": "팀 내 역할",
15
+ "type": ["owner", "admin", "member", "viewer"],
16
+ "default": "member"
17
+ },
18
+ "invited_by": {
19
+ "comment": "초대한 사용자 seq (fk: user.seq, nullable)"
20
+ },
21
+ "joined_at": {
22
+ "comment": "참가일시 (*_at → DATETIME 자동 추론)"
23
+ }
24
+ },
25
+ "unique": [["team_seq", "user_seq"]],
26
+ "fk": {
27
+ "invited_by": "user.seq"
28
+ }
29
+ }