create-entity-server 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bin/create.js +280 -0
  2. package/package.json +42 -0
  3. package/template/.env.example +14 -0
  4. package/template/configs/cache.json +22 -0
  5. package/template/configs/cors.json +7 -0
  6. package/template/configs/database.json +23 -0
  7. package/template/configs/jwt.json +7 -0
  8. package/template/configs/logging.json +45 -0
  9. package/template/configs/security.json +21 -0
  10. package/template/configs/server.json +10 -0
  11. package/template/entities/Account/account_audit.json +17 -0
  12. package/template/entities/Auth/account.json +60 -0
  13. package/template/entities/Auth/api_keys.json +26 -0
  14. package/template/entities/Auth/license.json +36 -0
  15. package/template/entities/Auth/rbac_roles.json +76 -0
  16. package/template/entities/README.md +380 -0
  17. package/template/entities/System/system_audit_log.json +65 -0
  18. package/template/entities/company.json +22 -0
  19. package/template/entities/product.json +36 -0
  20. package/template/entities/todo.json +16 -0
  21. package/template/samples/README.md +65 -0
  22. package/template/samples/flutter/lib/entity_server_client.dart +218 -0
  23. package/template/samples/flutter/pubspec.yaml +14 -0
  24. package/template/samples/java/EntityServerClient.java +304 -0
  25. package/template/samples/java/EntityServerExample.java +49 -0
  26. package/template/samples/kotlin/EntityServerClient.kt +194 -0
  27. package/template/samples/node/package.json +16 -0
  28. package/template/samples/node/src/EntityServerClient.js +246 -0
  29. package/template/samples/node/src/example.js +39 -0
  30. package/template/samples/php/ci4/Controllers/ProductController.php +141 -0
  31. package/template/samples/php/ci4/Libraries/EntityServer.php +260 -0
  32. package/template/samples/php/laravel/Http/Controllers/ProductController.php +62 -0
  33. package/template/samples/php/laravel/Services/EntityServerService.php +210 -0
  34. package/template/samples/python/entity_server.py +225 -0
  35. package/template/samples/python/example.py +50 -0
  36. package/template/samples/react/src/api/entityServerClient.ts +290 -0
  37. package/template/samples/react/src/example.tsx +127 -0
  38. package/template/samples/react/src/hooks/useEntity.ts +105 -0
  39. package/template/samples/swift/EntityServerClient.swift +221 -0
  40. package/template/scripts/api-key.ps1 +123 -0
  41. package/template/scripts/api-key.sh +130 -0
  42. package/template/scripts/cleanup-history.ps1 +69 -0
  43. package/template/scripts/cleanup-history.sh +54 -0
  44. package/template/scripts/cli.ps1 +24 -0
  45. package/template/scripts/cli.sh +27 -0
  46. package/template/scripts/entity.ps1 +70 -0
  47. package/template/scripts/entity.sh +72 -0
  48. package/template/scripts/generate-env-keys.ps1 +125 -0
  49. package/template/scripts/generate-env-keys.sh +148 -0
  50. package/template/scripts/install-systemd.sh +222 -0
  51. package/template/scripts/normalize-entities.ps1 +87 -0
  52. package/template/scripts/normalize-entities.sh +132 -0
  53. package/template/scripts/rbac-role.ps1 +124 -0
  54. package/template/scripts/rbac-role.sh +127 -0
  55. package/template/scripts/remove-systemd.sh +158 -0
  56. package/template/scripts/reset-all.ps1 +83 -0
  57. package/template/scripts/reset-all.sh +95 -0
  58. package/template/scripts/run.ps1 +239 -0
  59. package/template/scripts/run.sh +315 -0
  60. package/template/scripts/sync.ps1 +145 -0
  61. package/template/scripts/sync.sh +178 -0
  62. package/template/scripts/update-server.ps1 +117 -0
  63. package/template/scripts/update-server.sh +165 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Entity Server 클라이언트 (Kotlin / Android)
3
+ *
4
+ * 의존성 (build.gradle):
5
+ * implementation("org.bouncycastle:bcprov-jdk18on:1.80")
6
+ *
7
+ * 환경 설정:
8
+ * val client = EntityServerClient(
9
+ * baseUrl = "http://your-server:47200",
10
+ * apiKey = BuildConfig.ENTITY_API_KEY,
11
+ * hmacSecret = BuildConfig.ENTITY_HMAC_SECRET,
12
+ * magicLen = 4 // 서버 packet_magic_len 과 동일
13
+ * )
14
+ *
15
+ * 트랜잭션 사용 예:
16
+ * es.transStart()
17
+ * try {
18
+ * val orderRef = es.submit("order", JSONObject(mapOf("user_seq" to 1, "total" to 9900))) // seq: "\$tx.0"
19
+ * es.submit("order_item", JSONObject(mapOf("order_seq" to orderRef.getString("seq"), "item_seq" to 5))) // "\$tx.0" 자동 치환
20
+ * val result = es.transCommit()
21
+ * val orderSeq = (result.getJSONArray("results")).getJSONObject(0).getLong("seq") // 실제 seq
22
+ * } catch (e: Exception) {
23
+ * es.transRollback()
24
+ * }
25
+ */
26
+
27
+ package com.example.entityserver
28
+
29
+ import okhttp3.MediaType.Companion.toMediaType
30
+ import okhttp3.OkHttpClient
31
+ import okhttp3.Request
32
+ import okhttp3.RequestBody.Companion.toRequestBody
33
+ import org.bouncycastle.crypto.engines.ChaCha7539Engine
34
+ import org.bouncycastle.crypto.modes.ChaChaEngine
35
+ import org.bouncycastle.crypto.params.KeyParameter
36
+ import org.bouncycastle.crypto.params.ParametersWithIV
37
+ import org.json.JSONArray
38
+ import org.json.JSONObject
39
+ import java.security.MessageDigest
40
+ import java.util.UUID
41
+ import javax.crypto.Mac
42
+ import javax.crypto.spec.SecretKeySpec
43
+
44
+ class EntityServerClient(
45
+ private val baseUrl: String = "http://localhost:47200",
46
+ private val apiKey: String = "",
47
+ private val hmacSecret: String = "",
48
+ private val magicLen: Int = 4,
49
+ ) {
50
+ private val http = OkHttpClient()
51
+ private var activeTxId: String? = null
52
+
53
+ // ─── CRUD ─────────────────────────────────────────────────────────
54
+
55
+ fun get(entity: String, seq: Long): JSONObject =
56
+ request("GET", "/v1/entity/$entity/$seq")
57
+
58
+ fun list(entity: String, page: Int = 1, limit: Int = 20): JSONObject =
59
+ request("GET", "/v1/entity/$entity/list?page=$page&limit=$limit")
60
+
61
+ fun count(entity: String): JSONObject =
62
+ request("GET", "/v1/entity/$entity/count")
63
+
64
+ fun query(entity: String, filter: JSONArray, page: Int = 1, limit: Int = 20): JSONObject =
65
+ request("POST", "/v1/entity/$entity/query?page=$page&limit=$limit", filter.toString())
66
+
67
+ /**
68
+ * 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
69
+ * 이후 submit / delete 가 실제 실행되지 않고 서버 큐에 쌓입니다.
70
+ * transCommit() 시 한 번에 DB 트랜잭션으로 실행됩니다.
71
+ */
72
+ fun transStart(): String {
73
+ val res = request("POST", "/v1/transaction/start")
74
+ activeTxId = res.getString("transaction_id")
75
+ return activeTxId!!
76
+ }
77
+
78
+ /**
79
+ * 트랜잭션 전체 롤백
80
+ * transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 롤백합니다.
81
+ */
82
+ fun transRollback(transactionId: String? = null): JSONObject {
83
+ val txId = transactionId ?: activeTxId
84
+ ?: error("No active transaction. Call transStart() first.")
85
+ activeTxId = null
86
+ return request("POST", "/v1/transaction/rollback/$txId")
87
+ }
88
+
89
+ /**
90
+ * 트랜잭션 커밋 — 큐에 쌓인 모든 작업을 단일 DB 트랜잭션으로 일괄 실행합니다.
91
+ * transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 커밋합니다.
92
+ */
93
+ fun transCommit(transactionId: String? = null): JSONObject {
94
+ val txId = transactionId ?: activeTxId
95
+ ?: error("No active transaction. Call transStart() first.")
96
+ activeTxId = null
97
+ return request("POST", "/v1/transaction/commit/$txId")
98
+ }
99
+
100
+ /** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
101
+ fun submit(entity: String, data: JSONObject, transactionId: String? = null): JSONObject {
102
+ val txId = transactionId ?: activeTxId
103
+ val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
104
+ return request("POST", "/v1/entity/$entity/submit", data.toString(), extra)
105
+ }
106
+
107
+ /** 삭제 */
108
+ fun delete(entity: String, seq: Long, transactionId: String? = null, hard: Boolean = false): JSONObject {
109
+ val q = if (hard) "?hard=true" else ""
110
+ val txId = transactionId ?: activeTxId
111
+ val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
112
+ return request("DELETE", "/v1/entity/$entity/delete/$seq$q", extraHeaders = extra)
113
+ }
114
+
115
+ fun history(entity: String, seq: Long, page: Int = 1, limit: Int = 50): JSONObject =
116
+ request("GET", "/v1/entity/$entity/history/$seq?page=$page&limit=$limit")
117
+
118
+ fun rollback(entity: String, historySeq: Long): JSONObject =
119
+ request("POST", "/v1/entity/$entity/rollback/$historySeq")
120
+
121
+ // ─── 내부 ─────────────────────────────────────────────────────────
122
+
123
+ private fun request(method: String, path: String, bodyStr: String = "", extraHeaders: Map<String, String> = emptyMap()): JSONObject {
124
+ val timestamp = (System.currentTimeMillis() / 1000).toString()
125
+ val nonce = UUID.randomUUID().toString()
126
+ val signature = sign(method, path, timestamp, nonce, bodyStr)
127
+
128
+ val requestBuilder = Request.Builder()
129
+ .url(baseUrl.trimEnd('/') + path)
130
+ .addHeader("Content-Type", "application/json")
131
+ .addHeader("X-API-Key", apiKey)
132
+ .addHeader("X-Timestamp", timestamp)
133
+ .addHeader("X-Nonce", nonce)
134
+ .addHeader("X-Signature", signature)
135
+ .apply { extraHeaders.forEach { (k, v) -> addHeader(k, v) } }
136
+
137
+ val body = if (bodyStr.isNotEmpty())
138
+ bodyStr.toRequestBody("application/json".toMediaType())
139
+ else null
140
+
141
+ val req = when (method.uppercase()) {
142
+ "GET" -> requestBuilder.get().build()
143
+ "DELETE" -> requestBuilder.delete(body).build()
144
+ else -> requestBuilder.method(method.uppercase(), body).build()
145
+ }
146
+
147
+ val res = http.newCall(req).execute()
148
+ val contentType = res.header("Content-Type") ?: ""
149
+ val rawBytes = res.body?.bytes() ?: byteArrayOf()
150
+
151
+ // 패킷 암호화 응답: application/octet-stream → 복호화
152
+ return if (contentType.contains("application/octet-stream")) {
153
+ JSONObject(decryptPacket(rawBytes))
154
+ } else {
155
+ JSONObject(String(rawBytes, Charsets.UTF_8))
156
+ }
157
+ }
158
+
159
+ /**
160
+ * XChaCha20-Poly1305 패킷 복호화
161
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
162
+ * 키: sha256(hmac_secret)
163
+ *
164
+ * Bouncy Castle XChaCha20-Poly1305 사용
165
+ */
166
+ private fun decryptPacket(data: ByteArray): String {
167
+ val key = sha256(hmacSecret.toByteArray(Charsets.UTF_8))
168
+
169
+ val nonce = data.copyOfRange(magicLen, magicLen + 24)
170
+ val ciphertext = data.copyOfRange(magicLen + 24, data.size)
171
+
172
+ // Bouncy Castle: XChaCha20-Poly1305 (AEAD)
173
+ val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
174
+ aead.init(false, org.bouncycastle.crypto.params.AEADParameters(
175
+ KeyParameter(key), 128, nonce
176
+ ))
177
+
178
+ val plaintext = ByteArray(aead.getOutputSize(ciphertext.size))
179
+ val len = aead.processBytes(ciphertext, 0, ciphertext.size, plaintext, 0)
180
+ aead.doFinal(plaintext, len)
181
+ return plaintext.toString(Charsets.UTF_8)
182
+ }
183
+
184
+ /** HMAC-SHA256 서명 */
185
+ private fun sign(method: String, path: String, timestamp: String, nonce: String, body: String): String {
186
+ val payload = listOf(method, path, timestamp, nonce, body).joinToString("|")
187
+ val mac = Mac.getInstance("HmacSHA256")
188
+ mac.init(SecretKeySpec(hmacSecret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
189
+ return mac.doFinal(payload.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
190
+ }
191
+
192
+ private fun sha256(input: ByteArray): ByteArray =
193
+ MessageDigest.getInstance("SHA-256").digest(input)
194
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "entity-server-client-sample",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Entity Server HMAC 클라이언트 샘플 (Node.js)",
6
+ "scripts": {
7
+ "example": "node src/example.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "dependencies": {
13
+ "@noble/ciphers": "^1.3.0",
14
+ "@noble/hashes": "^1.7.2"
15
+ }
16
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Entity Server 클라이언트 (Node.js)
3
+ *
4
+ * 의존성: Node.js 18+, @noble/ciphers, @noble/hashes
5
+ * npm install @noble/ciphers @noble/hashes
6
+ *
7
+ * 환경변수:
8
+ * ENTITY_SERVER_URL http://localhost:47200
9
+ * ENTITY_SERVER_API_KEY your-api-key
10
+ * ENTITY_SERVER_HMAC_SECRET your-hmac-secret
11
+ * ENTITY_SERVER_MAGIC_LEN 4 (서버 packet_magic_len 과 동일하게)
12
+ *
13
+ * 사용 예:
14
+ * const es = new EntityServerClient();
15
+ * const list = await es.list('account', { page: 1, limit: 20 });
16
+ * const seq = await es.submit('account', { name: '홍길동' });
17
+ *
18
+ * 트랜잭션 사용 예:
19
+ * await es.transStart();
20
+ * try {
21
+ * const orderRef = await es.submit('order', { ... }); // seq: "$tx.0"
22
+ * await es.submit('order_item', { order_seq: orderRef.seq }); // "$tx.0" 자동 치환
23
+ * const result = await es.transCommit();
24
+ * const orderSeq = result.results[0].seq; // 실제 seq
25
+ * } catch (e) {
26
+ * await es.transRollback();
27
+ * }
28
+ */
29
+
30
+ import { createHmac, randomUUID } from "crypto";
31
+ import { xchacha20_poly1305 } from "@noble/ciphers/chacha";
32
+ import { sha256 } from "@noble/hashes/sha2";
33
+
34
+ export class EntityServerClient {
35
+ #baseUrl;
36
+ #apiKey;
37
+ #hmacSecret;
38
+ #magicLen;
39
+ #activeTxId = null;
40
+
41
+ constructor({
42
+ baseUrl = process.env.ENTITY_SERVER_URL ?? "http://localhost:47200",
43
+ apiKey = process.env.ENTITY_SERVER_API_KEY ?? "",
44
+ hmacSecret = process.env.ENTITY_SERVER_HMAC_SECRET ?? "",
45
+ magicLen = Number(process.env.ENTITY_SERVER_MAGIC_LEN ?? "4"),
46
+ } = {}) {
47
+ this.#baseUrl = baseUrl.replace(/\/$/, "");
48
+ this.#apiKey = apiKey;
49
+ this.#hmacSecret = hmacSecret;
50
+ this.#magicLen = magicLen > 0 ? magicLen : 4;
51
+ }
52
+
53
+ // ─── 트랜잭션 ─────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * 트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
57
+ * 이후 submit / delete 가 서버 큐에 쌓이고 transCommit() 시 일괄 처리됩니다.
58
+ * @returns {Promise<string>} transaction_id
59
+ */
60
+ async transStart() {
61
+ const res = await this.#request("POST", "/v1/transaction/start");
62
+ this.#activeTxId = res.transaction_id;
63
+ return this.#activeTxId;
64
+ }
65
+
66
+ /**
67
+ * 트랜잭션 단위로 변경사항을 롤백합니다.
68
+ * @param {string} [transactionId] 생략 시 transStart() 로 시작한 활성 트랜잭션 사용
69
+ */
70
+ transRollback(transactionId) {
71
+ const txId = transactionId ?? this.#activeTxId;
72
+ if (!txId)
73
+ throw new Error("No active transaction. Call transStart() first.");
74
+ this.#activeTxId = null;
75
+ return this.#request("POST", `/v1/transaction/rollback/${txId}`);
76
+ }
77
+
78
+ /**
79
+ * 트랜잭션 커밋 — 서버 큐에 쌓인 작업들을 단일 DB 트랜잭션으로 일괄 처리합니다.
80
+ * @param {string} [transactionId] 생략 시 transStart() 로 시작한 활성 트랜잭션 사용
81
+ */
82
+ transCommit(transactionId) {
83
+ const txId = transactionId ?? this.#activeTxId;
84
+ if (!txId)
85
+ throw new Error("No active transaction. Call transStart() first.");
86
+ this.#activeTxId = null;
87
+ return this.#request("POST", `/v1/transaction/commit/${txId}`);
88
+ }
89
+
90
+ // ─── CRUD ────────────────────────────────────────────────────────────────
91
+
92
+ /** 단건 조회 */
93
+ get(entity, seq) {
94
+ return this.#request("GET", `/v1/entity/${entity}/${seq}`);
95
+ }
96
+
97
+ /** 목록 조회 */
98
+ list(entity, { page = 1, limit = 20, orderBy } = {}) {
99
+ const q = new URLSearchParams({
100
+ page,
101
+ limit,
102
+ ...(orderBy && { order_by: orderBy }),
103
+ });
104
+ return this.#request("GET", `/v1/entity/${entity}/list?${q}`);
105
+ }
106
+
107
+ /** 건수 조회 */
108
+ count(entity) {
109
+ return this.#request("GET", `/v1/entity/${entity}/count`);
110
+ }
111
+
112
+ /**
113
+ * 필터 검색
114
+ * @param {Array} filter 예: [{ field: 'status', op: 'eq', value: 'active' }]
115
+ * @param {Object} params 예: { page: 1, limit: 20, orderBy: 'name' }
116
+ */
117
+ query(entity, filter = [], { page = 1, limit = 20, orderBy } = {}) {
118
+ const q = new URLSearchParams({
119
+ page,
120
+ limit,
121
+ ...(orderBy && { order_by: orderBy }),
122
+ });
123
+ return this.#request("POST", `/v1/entity/${entity}/query?${q}`, filter);
124
+ }
125
+
126
+ /**
127
+ * 생성 또는 수정
128
+ * body에 seq 포함 시 수정, 없으면 생성
129
+ * @param {string} entity
130
+ * @param {Object} data
131
+ * @param {Object} [opts]
132
+ * @param {string} [opts.transactionId] transStart() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
133
+ */
134
+ submit(entity, data, { transactionId } = {}) {
135
+ const txId = transactionId ?? this.#activeTxId;
136
+ const extra = txId ? { "X-Transaction-ID": txId } : {};
137
+ return this.#request(
138
+ "POST",
139
+ `/v1/entity/${entity}/submit`,
140
+ data,
141
+ extra,
142
+ );
143
+ }
144
+
145
+ /**
146
+ * 삭제
147
+ * @param {string} entity
148
+ * @param {number} seq
149
+ * @param {Object} [opts]
150
+ * @param {string} [opts.transactionId] transStart() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
151
+ * @param {boolean} [opts.hard] 하드 삭제 여부 (기본 false)
152
+ */
153
+ delete(entity, seq, { transactionId, hard = false } = {}) {
154
+ const q = hard ? "?hard=true" : "";
155
+ const txId = transactionId ?? this.#activeTxId;
156
+ const extra = txId ? { "X-Transaction-ID": txId } : {};
157
+ return this.#request(
158
+ "DELETE",
159
+ `/v1/entity/${entity}/delete/${seq}${q}`,
160
+ null,
161
+ extra,
162
+ );
163
+ }
164
+
165
+ /** 변경 이력 조회 */
166
+ history(entity, seq, { page = 1, limit = 50 } = {}) {
167
+ return this.#request(
168
+ "GET",
169
+ `/v1/entity/${entity}/history/${seq}?page=${page}&limit=${limit}`,
170
+ );
171
+ }
172
+
173
+ /** history seq 단위 롤백 (단건) */
174
+ rollback(entity, historySeq) {
175
+ return this.#request(
176
+ "POST",
177
+ `/v1/entity/${entity}/rollback/${historySeq}`,
178
+ );
179
+ }
180
+
181
+ // ─── 내부 ─────────────────────────────────────────────────────────────────
182
+
183
+ async #request(method, path, body, extraHeaders = {}) {
184
+ const bodyStr = body != null ? JSON.stringify(body) : "";
185
+ const timestamp = String(Math.floor(Date.now() / 1000));
186
+ const nonce = randomUUID();
187
+ const signature = this.#sign(method, path, timestamp, nonce, bodyStr);
188
+
189
+ const headers = {
190
+ "Content-Type": "application/json",
191
+ "X-API-Key": this.#apiKey,
192
+ "X-Timestamp": timestamp,
193
+ "X-Nonce": nonce,
194
+ "X-Signature": signature,
195
+ ...extraHeaders,
196
+ };
197
+
198
+ const res = await fetch(this.#baseUrl + path, {
199
+ method,
200
+ headers,
201
+ ...(bodyStr ? { body: bodyStr } : {}),
202
+ });
203
+
204
+ const contentType = res.headers.get("Content-Type") ?? "";
205
+
206
+ // 패킷 암호화 응답: application/octet-stream → 복호화
207
+ if (contentType.includes("application/octet-stream")) {
208
+ const buffer = await res.arrayBuffer();
209
+ return this.#decryptPacket(buffer);
210
+ }
211
+
212
+ const data = await res.json();
213
+
214
+ if (!data.ok) {
215
+ throw new Error(
216
+ `EntityServer error: ${data.message ?? "Unknown"} (HTTP ${res.status})`,
217
+ );
218
+ }
219
+ return data;
220
+ }
221
+
222
+ /**
223
+ * XChaCha20-Poly1305 패킷 복호화
224
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
225
+ * 키: sha256(hmac_secret)
226
+ */
227
+ #decryptPacket(buffer) {
228
+ const key = sha256(new TextEncoder().encode(this.#hmacSecret));
229
+ const data = new Uint8Array(buffer);
230
+ const nonce = data.slice(this.#magicLen, this.#magicLen + 24);
231
+ const ciphertext = data.slice(this.#magicLen + 24);
232
+ const cipher = xchacha20_poly1305(key, nonce);
233
+ const plaintext = cipher.decrypt(ciphertext);
234
+ return JSON.parse(new TextDecoder().decode(plaintext));
235
+ }
236
+
237
+ /** HMAC-SHA256 서명 */
238
+ #sign(method, path, timestamp, nonce, body) {
239
+ const payload = [method, path, timestamp, nonce, body].join("|");
240
+ return createHmac("sha256", this.#hmacSecret)
241
+ .update(payload)
242
+ .digest("hex");
243
+ }
244
+ }
245
+
246
+ export default EntityServerClient;
@@ -0,0 +1,39 @@
1
+ import { EntityServerClient } from "./EntityServerClient.js";
2
+
3
+ const es = new EntityServerClient({
4
+ baseUrl: process.env.ENTITY_SERVER_URL ?? "http://localhost:47200",
5
+ apiKey: process.env.ENTITY_SERVER_API_KEY ?? "mykey",
6
+ hmacSecret: process.env.ENTITY_SERVER_HMAC_SECRET ?? "mysecret",
7
+ });
8
+
9
+ // 목록 조회
10
+ const list = await es.list("product", { page: 1, limit: 10 });
11
+ console.log("List:", list.data?.length, "items");
12
+
13
+ // 생성
14
+ const created = await es.submit("product", {
15
+ name: "무선 키보드",
16
+ price: 89000,
17
+ category: "peripherals",
18
+ });
19
+ console.log("Created seq:", created.seq);
20
+
21
+ // 수정 (seq 포함)
22
+ await es.submit("product", { seq: created.seq, price: 79000 });
23
+ console.log("Updated");
24
+
25
+ // 필터 검색
26
+ const results = await es.query(
27
+ "product",
28
+ [{ field: "category", op: "eq", value: "peripherals" }],
29
+ { page: 1, limit: 5 },
30
+ );
31
+ console.log("Query results:", results.data?.length);
32
+
33
+ // 이력 조회
34
+ const hist = await es.history("product", created.seq);
35
+ console.log("History count:", hist.data?.length);
36
+
37
+ // 삭제
38
+ await es.delete("product", created.seq);
39
+ console.log("Deleted");
@@ -0,0 +1,141 @@
1
+ <?php
2
+
3
+ namespace App\Controllers;
4
+
5
+ use App\Controllers\BaseController;
6
+ use App\Libraries\EntityServer;
7
+
8
+ /**
9
+ * EntityServer 라이브러리를 사용하는 CI4 컨트롤러 예시
10
+ */
11
+ class ProductController extends BaseController
12
+ {
13
+ private EntityServer $es;
14
+
15
+ public function __construct()
16
+ {
17
+ $this->es = new EntityServer();
18
+ }
19
+
20
+ /** GET /products */
21
+ public function index(): string
22
+ {
23
+ $page = (int) ($this->request->getGet('page') ?? 1);
24
+ $limit = (int) ($this->request->getGet('limit') ?? 20);
25
+ $result = $this->es->list('product', ['page' => $page, 'limit' => $limit]);
26
+
27
+ return $this->response->setJSON($result)->getBody();
28
+ }
29
+
30
+ /** GET /products/(:num) */
31
+ public function show(int $seq): string
32
+ {
33
+ $result = $this->es->get('product', $seq);
34
+ return $this->response->setJSON($result)->getBody();
35
+ }
36
+
37
+ /** POST /products/search */
38
+ public function search(): string
39
+ {
40
+ $body = $this->request->getJSON(true);
41
+ $filter = $body['filter'] ?? [];
42
+ $params = ['page' => $body['page'] ?? 1, 'limit' => $body['limit'] ?? 20];
43
+
44
+ // 필터 예: [['field' => 'category', 'op' => 'eq', 'value' => 'electronics']]
45
+ $result = $this->es->query('product', $filter, $params);
46
+ return $this->response->setJSON($result)->getBody();
47
+ }
48
+
49
+ /** POST /products */
50
+ public function create(): string
51
+ {
52
+ $data = $this->request->getJSON(true);
53
+ // seq 없이 submit → 생성
54
+ $result = $this->es->submit('product', $data);
55
+ return $this->response->setStatusCode(201)->setJSON($result)->getBody();
56
+ }
57
+
58
+ /** PUT /products/(:num) */
59
+ public function update(int $seq): string
60
+ {
61
+ $data = $this->request->getJSON(true);
62
+ $data['seq'] = $seq; // seq 포함 → 수정
63
+ $result = $this->es->submit('product', $data);
64
+ return $this->response->setJSON($result)->getBody();
65
+ }
66
+
67
+ /** DELETE /products/(:num) */
68
+ public function delete(int $seq): string
69
+ {
70
+ $result = $this->es->delete('product', $seq);
71
+ return $this->response->setJSON($result)->getBody();
72
+ }
73
+
74
+ /** GET /products/(:num)/history */
75
+ public function history(int $seq): string
76
+ {
77
+ $result = $this->es->history('product', $seq);
78
+ return $this->response->setJSON($result)->getBody();
79
+ }
80
+
81
+ /**
82
+ * POST /products/order
83
+ *
84
+ * 트랜잭션 예시: 상품 재고 차감 + 주문 생성을 하나의 DB 트랜잭션으로 처리.
85
+ * submit 요청은 서버 큐에 쌓이고 transCommit() 시 단일 DB 트랜잭션으로 일괄 커밋됩니다.
86
+ * 실패 시 transRollback() 으로 큐를 버립니다.
87
+ *
88
+ * 요청 body 예:
89
+ * { "product_seq": 5, "qty": 2, "buyer": "홍길동" }
90
+ */
91
+ public function order(): string
92
+ {
93
+ $body = $this->request->getJSON(true);
94
+ $productSeq = (int) ($body['product_seq'] ?? 0);
95
+ $qty = (int) ($body['qty'] ?? 1);
96
+ $buyer = $body['buyer'] ?? '';
97
+
98
+ if (!$productSeq) {
99
+ return $this->response->setStatusCode(400)
100
+ ->setJSON(['ok' => false, 'message' => 'product_seq required'])
101
+ ->getBody();
102
+ }
103
+
104
+ $this->es->transStart(); // 서버 큐 등록, 이후 submit / delete 시 큐에씀임
105
+
106
+ try {
107
+ // 1) 상품 조회 후 재고 차감
108
+ $product = $this->es->get('product', $productSeq);
109
+ $stock = (int) ($product['data']['stock'] ?? 0);
110
+ if ($stock < $qty) {
111
+ throw new \RuntimeException('재고 부족');
112
+ }
113
+ $this->es->submit('product', [
114
+ 'seq' => $productSeq,
115
+ 'stock' => $stock - $qty,
116
+ ]);
117
+
118
+ // 2) 주문 생성
119
+ $this->es->submit('order', [
120
+ 'product_seq' => $productSeq,
121
+ 'qty' => $qty,
122
+ 'buyer' => $buyer,
123
+ 'status' => 'pending',
124
+ ]);
125
+
126
+ // 3) 단일 DB 트랜잭션으로 일괄 커밋
127
+ // results[0] = product update, results[1] = order insert
128
+ $commitResult = $this->es->transCommit();
129
+ $orderSeq = $commitResult['results'][1]['seq'] ?? null;
130
+
131
+ return $this->response->setStatusCode(201)
132
+ ->setJSON(['ok' => true, 'order_seq' => $orderSeq])
133
+ ->getBody();
134
+ } catch (\Throwable $e) {
135
+ $this->es->transRollback(); // 큐 버림 (아직 커밋 안 된 경우) 또는 saga 롤백
136
+ return $this->response->setStatusCode(500)
137
+ ->setJSON(['ok' => false, 'message' => $e->getMessage()])
138
+ ->getBody();
139
+ }
140
+ }
141
+ }