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.
- package/bin/create.js +280 -0
- package/package.json +42 -0
- package/template/.env.example +14 -0
- package/template/configs/cache.json +22 -0
- package/template/configs/cors.json +7 -0
- package/template/configs/database.json +23 -0
- package/template/configs/jwt.json +7 -0
- package/template/configs/logging.json +45 -0
- package/template/configs/security.json +21 -0
- package/template/configs/server.json +10 -0
- package/template/entities/Account/account_audit.json +17 -0
- package/template/entities/Auth/account.json +60 -0
- package/template/entities/Auth/api_keys.json +26 -0
- package/template/entities/Auth/license.json +36 -0
- package/template/entities/Auth/rbac_roles.json +76 -0
- package/template/entities/README.md +380 -0
- package/template/entities/System/system_audit_log.json +65 -0
- package/template/entities/company.json +22 -0
- package/template/entities/product.json +36 -0
- package/template/entities/todo.json +16 -0
- package/template/samples/README.md +65 -0
- package/template/samples/flutter/lib/entity_server_client.dart +218 -0
- package/template/samples/flutter/pubspec.yaml +14 -0
- package/template/samples/java/EntityServerClient.java +304 -0
- package/template/samples/java/EntityServerExample.java +49 -0
- package/template/samples/kotlin/EntityServerClient.kt +194 -0
- package/template/samples/node/package.json +16 -0
- package/template/samples/node/src/EntityServerClient.js +246 -0
- package/template/samples/node/src/example.js +39 -0
- package/template/samples/php/ci4/Controllers/ProductController.php +141 -0
- package/template/samples/php/ci4/Libraries/EntityServer.php +260 -0
- package/template/samples/php/laravel/Http/Controllers/ProductController.php +62 -0
- package/template/samples/php/laravel/Services/EntityServerService.php +210 -0
- package/template/samples/python/entity_server.py +225 -0
- package/template/samples/python/example.py +50 -0
- package/template/samples/react/src/api/entityServerClient.ts +290 -0
- package/template/samples/react/src/example.tsx +127 -0
- package/template/samples/react/src/hooks/useEntity.ts +105 -0
- package/template/samples/swift/EntityServerClient.swift +221 -0
- package/template/scripts/api-key.ps1 +123 -0
- package/template/scripts/api-key.sh +130 -0
- package/template/scripts/cleanup-history.ps1 +69 -0
- package/template/scripts/cleanup-history.sh +54 -0
- package/template/scripts/cli.ps1 +24 -0
- package/template/scripts/cli.sh +27 -0
- package/template/scripts/entity.ps1 +70 -0
- package/template/scripts/entity.sh +72 -0
- package/template/scripts/generate-env-keys.ps1 +125 -0
- package/template/scripts/generate-env-keys.sh +148 -0
- package/template/scripts/install-systemd.sh +222 -0
- package/template/scripts/normalize-entities.ps1 +87 -0
- package/template/scripts/normalize-entities.sh +132 -0
- package/template/scripts/rbac-role.ps1 +124 -0
- package/template/scripts/rbac-role.sh +127 -0
- package/template/scripts/remove-systemd.sh +158 -0
- package/template/scripts/reset-all.ps1 +83 -0
- package/template/scripts/reset-all.sh +95 -0
- package/template/scripts/run.ps1 +239 -0
- package/template/scripts/run.sh +315 -0
- package/template/scripts/sync.ps1 +145 -0
- package/template/scripts/sync.sh +178 -0
- package/template/scripts/update-server.ps1 +117 -0
- 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
|
+
}
|