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,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entity Server 클라이언트 (Python)
|
|
3
|
+
|
|
4
|
+
의존성:
|
|
5
|
+
pip install requests cryptography
|
|
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_PACKET_MAGIC_LEN 4 (서버 packet_magic_len 과 동일)
|
|
12
|
+
|
|
13
|
+
사용 예:
|
|
14
|
+
es = EntityServerClient()
|
|
15
|
+
result = es.get("account", 1)
|
|
16
|
+
items = es.list("account", page=1, limit=20)
|
|
17
|
+
seq = es.submit("account", {"name": "홍길동", "email": "hong@example.com"})
|
|
18
|
+
|
|
19
|
+
트랜잭션 사용 예:
|
|
20
|
+
es.trans_start()
|
|
21
|
+
try:
|
|
22
|
+
order_ref = es.submit("order", {...}) # seq: "$tx.0"
|
|
23
|
+
es.submit("order_item", {"order_seq": order_ref["seq"], ...}) # "$tx.0" 자동 치환
|
|
24
|
+
result = es.trans_commit()
|
|
25
|
+
order_seq = result["results"][0]["seq"] # 실제 seq
|
|
26
|
+
except Exception:
|
|
27
|
+
es.trans_rollback()
|
|
28
|
+
raise
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import hmac
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
import requests
|
|
42
|
+
from cryptography.hazmat.primitives.ciphers.aead import XChaCha20Poly1305
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EntityServerClient:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
base_url: str = "",
|
|
49
|
+
api_key: str = "",
|
|
50
|
+
hmac_secret: str = "",
|
|
51
|
+
timeout: int = 10,
|
|
52
|
+
magic_len: int = 4,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.base_url = (base_url or os.getenv("ENTITY_SERVER_URL", "http://localhost:47200")).rstrip("/")
|
|
55
|
+
self.api_key = api_key or os.getenv("ENTITY_SERVER_API_KEY", "")
|
|
56
|
+
self.hmac_secret = hmac_secret or os.getenv("ENTITY_SERVER_HMAC_SECRET", "")
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
self.magic_len = int(os.getenv("ENTITY_PACKET_MAGIC_LEN", magic_len))
|
|
59
|
+
self._session = requests.Session()
|
|
60
|
+
self._active_tx_id: str | None = None
|
|
61
|
+
|
|
62
|
+
# ─── 트랜잭션 ──────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def trans_start(self) -> str:
|
|
65
|
+
"""
|
|
66
|
+
트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
|
|
67
|
+
이후 submit / delete 가 서버 큐에 쌓이고 trans_commit() 시 일괄 처리됩니다.
|
|
68
|
+
"""
|
|
69
|
+
result = self._request("POST", "/v1/transaction/start")
|
|
70
|
+
self._active_tx_id = result["transaction_id"]
|
|
71
|
+
return self._active_tx_id
|
|
72
|
+
|
|
73
|
+
def trans_rollback(self, transaction_id: str | None = None) -> dict:
|
|
74
|
+
"""트랜잭션 단위로 변경사항을 롤백합니다.
|
|
75
|
+
transaction_id 생략 시 trans_start() 로 시작한 활성 트랜잭션을 사용합니다."""
|
|
76
|
+
tx_id = transaction_id or self._active_tx_id
|
|
77
|
+
if not tx_id:
|
|
78
|
+
raise RuntimeError("No active transaction. Call trans_start() first.")
|
|
79
|
+
self._active_tx_id = None
|
|
80
|
+
return self._request("POST", f"/v1/transaction/rollback/{tx_id}")
|
|
81
|
+
|
|
82
|
+
def trans_commit(self, transaction_id: str | None = None) -> dict:
|
|
83
|
+
"""트랜잭션 커밋 — 서버 큐에 쌓인 작업을 단일 DB 트랜잭션으로 일괄 처리합니다.
|
|
84
|
+
transaction_id 생략 시 trans_start() 로 시작한 활성 트랜잭션을 사용합니다."""
|
|
85
|
+
tx_id = transaction_id or self._active_tx_id
|
|
86
|
+
if not tx_id:
|
|
87
|
+
raise RuntimeError("No active transaction. Call trans_start() first.")
|
|
88
|
+
self._active_tx_id = None
|
|
89
|
+
return self._request("POST", f"/v1/transaction/commit/{tx_id}")
|
|
90
|
+
|
|
91
|
+
# ─── CRUD ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def get(self, entity: str, seq: int) -> dict:
|
|
94
|
+
"""단건 조회"""
|
|
95
|
+
return self._request("GET", f"/v1/entity/{entity}/{seq}")
|
|
96
|
+
|
|
97
|
+
def list(self, entity: str, page: int = 1, limit: int = 20, order_by: str | None = None) -> dict:
|
|
98
|
+
"""목록 조회"""
|
|
99
|
+
params: dict = {"page": page, "limit": limit}
|
|
100
|
+
if order_by:
|
|
101
|
+
params["order_by"] = order_by
|
|
102
|
+
return self._request("GET", f"/v1/entity/{entity}/list", params=params)
|
|
103
|
+
|
|
104
|
+
def count(self, entity: str) -> dict:
|
|
105
|
+
"""건수 조회"""
|
|
106
|
+
return self._request("GET", f"/v1/entity/{entity}/count")
|
|
107
|
+
|
|
108
|
+
def query(
|
|
109
|
+
self,
|
|
110
|
+
entity: str,
|
|
111
|
+
filter: list[dict] | None = None,
|
|
112
|
+
page: int = 1,
|
|
113
|
+
limit: int = 20,
|
|
114
|
+
order_by: str | None = None,
|
|
115
|
+
) -> dict:
|
|
116
|
+
"""
|
|
117
|
+
필터 검색
|
|
118
|
+
filter 예: [{"field": "status", "op": "eq", "value": "active"}]
|
|
119
|
+
"""
|
|
120
|
+
params: dict = {"page": page, "limit": limit}
|
|
121
|
+
if order_by:
|
|
122
|
+
params["order_by"] = order_by
|
|
123
|
+
return self._request("POST", f"/v1/entity/{entity}/query", body=filter or [], params=params)
|
|
124
|
+
|
|
125
|
+
def submit(self, entity: str, data: dict, *, transaction_id: str | None = None) -> dict:
|
|
126
|
+
"""
|
|
127
|
+
생성 또는 수정
|
|
128
|
+
data에 'seq' 포함 시 수정, 없으면 생성
|
|
129
|
+
:param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
130
|
+
"""
|
|
131
|
+
tx_id = transaction_id or self._active_tx_id
|
|
132
|
+
extra = {"X-Transaction-ID": tx_id} if tx_id else {}
|
|
133
|
+
return self._request("POST", f"/v1/entity/{entity}/submit", body=data, extra_headers=extra)
|
|
134
|
+
|
|
135
|
+
def delete(self, entity: str, seq: int, *, transaction_id: str | None = None, hard: bool = False) -> dict:
|
|
136
|
+
"""삭제
|
|
137
|
+
:param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
138
|
+
:param hard: True 시 하드 삭제
|
|
139
|
+
"""
|
|
140
|
+
params = {"hard": "true"} if hard else {}
|
|
141
|
+
tx_id = transaction_id or self._active_tx_id
|
|
142
|
+
extra = {"X-Transaction-ID": tx_id} if tx_id else {}
|
|
143
|
+
return self._request("DELETE", f"/v1/entity/{entity}/delete/{seq}", params=params, extra_headers=extra)
|
|
144
|
+
|
|
145
|
+
def history(self, entity: str, seq: int, page: int = 1, limit: int = 50) -> dict:
|
|
146
|
+
"""변경 이력 조회"""
|
|
147
|
+
return self._request("GET", f"/v1/entity/{entity}/history/{seq}", params={"page": page, "limit": limit})
|
|
148
|
+
|
|
149
|
+
def rollback(self, entity: str, history_seq: int) -> dict:
|
|
150
|
+
"""history seq 단위 롤백 (단건)"""
|
|
151
|
+
return self._request("POST", f"/v1/entity/{entity}/rollback/{history_seq}")
|
|
152
|
+
|
|
153
|
+
# ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def _request(
|
|
156
|
+
self,
|
|
157
|
+
method: str,
|
|
158
|
+
path: str,
|
|
159
|
+
body: Any = None,
|
|
160
|
+
params: dict | None = None,
|
|
161
|
+
extra_headers: dict | None = None,
|
|
162
|
+
) -> dict:
|
|
163
|
+
# 쿼리스트링 포함 전체 경로 (서명 대상)
|
|
164
|
+
if params:
|
|
165
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
|
166
|
+
signed_path = f"{path}?{qs}"
|
|
167
|
+
else:
|
|
168
|
+
signed_path = path
|
|
169
|
+
|
|
170
|
+
body_str = json.dumps(body, ensure_ascii=False) if body is not None else ""
|
|
171
|
+
timestamp = str(int(time.time()))
|
|
172
|
+
nonce = str(uuid.uuid4())
|
|
173
|
+
signature = self._sign(method, signed_path, timestamp, nonce, body_str)
|
|
174
|
+
|
|
175
|
+
headers: dict = {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
"X-API-Key": self.api_key,
|
|
178
|
+
"X-Timestamp": timestamp,
|
|
179
|
+
"X-Nonce": nonce,
|
|
180
|
+
"X-Signature": signature,
|
|
181
|
+
}
|
|
182
|
+
if extra_headers:
|
|
183
|
+
headers.update(extra_headers)
|
|
184
|
+
|
|
185
|
+
url = self.base_url + path
|
|
186
|
+
resp = self._session.request(
|
|
187
|
+
method=method,
|
|
188
|
+
url=url,
|
|
189
|
+
headers=headers,
|
|
190
|
+
data=body_str.encode("utf-8") if body_str else None,
|
|
191
|
+
params=params,
|
|
192
|
+
timeout=self.timeout,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# 패킷 암호화 응답: application/octet-stream → 복호화
|
|
196
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
197
|
+
if "application/octet-stream" in content_type:
|
|
198
|
+
data = json.loads(self._decrypt_packet(resp.content))
|
|
199
|
+
else:
|
|
200
|
+
data = resp.json()
|
|
201
|
+
|
|
202
|
+
if not data.get("ok"):
|
|
203
|
+
raise RuntimeError(f"EntityServer error: {data.get('message', 'Unknown')} (HTTP {resp.status_code})")
|
|
204
|
+
|
|
205
|
+
return data
|
|
206
|
+
|
|
207
|
+
def _decrypt_packet(self, data: bytes) -> bytes:
|
|
208
|
+
"""
|
|
209
|
+
XChaCha20-Poly1305 패킷 복호화
|
|
210
|
+
포맷: [magic:magic_len][nonce:24][ciphertext+tag]
|
|
211
|
+
키: sha256(hmac_secret)
|
|
212
|
+
"""
|
|
213
|
+
key = hashlib.sha256(self.hmac_secret.encode("utf-8")).digest()
|
|
214
|
+
nonce = data[self.magic_len : self.magic_len + 24]
|
|
215
|
+
ciphertext = data[self.magic_len + 24 :]
|
|
216
|
+
return XChaCha20Poly1305(key).decrypt(nonce, ciphertext, b"")
|
|
217
|
+
|
|
218
|
+
def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: str) -> str:
|
|
219
|
+
"""HMAC-SHA256 서명"""
|
|
220
|
+
payload = "|".join([method, path, timestamp, nonce, body])
|
|
221
|
+
return hmac.new(
|
|
222
|
+
key=self.hmac_secret.encode("utf-8"),
|
|
223
|
+
msg=payload.encode("utf-8"),
|
|
224
|
+
digestmod=hashlib.sha256,
|
|
225
|
+
).hexdigest()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EntityServerClient 사용 예제
|
|
3
|
+
|
|
4
|
+
실행:
|
|
5
|
+
pip install requests
|
|
6
|
+
ENTITY_SERVER_URL=http://localhost:47200 \
|
|
7
|
+
ENTITY_SERVER_API_KEY=mykey \
|
|
8
|
+
ENTITY_SERVER_HMAC_SECRET=mysecret \
|
|
9
|
+
python example.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from entity_server import EntityServerClient
|
|
13
|
+
|
|
14
|
+
es = EntityServerClient()
|
|
15
|
+
|
|
16
|
+
# 목록 조회
|
|
17
|
+
result = es.list("product", page=1, limit=10)
|
|
18
|
+
print(f"List: {len(result.get('data', []))} items")
|
|
19
|
+
|
|
20
|
+
# 생성
|
|
21
|
+
created = es.submit("product", {
|
|
22
|
+
"name": "무선 마우스",
|
|
23
|
+
"price": 45000,
|
|
24
|
+
"category": "peripherals",
|
|
25
|
+
})
|
|
26
|
+
seq = created.get("seq")
|
|
27
|
+
print(f"Created seq: {seq}")
|
|
28
|
+
|
|
29
|
+
# 단건 조회
|
|
30
|
+
item = es.get("product", seq)
|
|
31
|
+
print(f"Get: {item['data']['name']}")
|
|
32
|
+
|
|
33
|
+
# 수정 (seq 포함)
|
|
34
|
+
es.submit("product", {"seq": seq, "price": 39000})
|
|
35
|
+
print("Updated")
|
|
36
|
+
|
|
37
|
+
# 필터 검색
|
|
38
|
+
results = es.query("product",
|
|
39
|
+
filter=[{"field": "category", "op": "eq", "value": "peripherals"}],
|
|
40
|
+
page=1, limit=5,
|
|
41
|
+
)
|
|
42
|
+
print(f"Query: {len(results.get('data', []))} results")
|
|
43
|
+
|
|
44
|
+
# 이력 조회
|
|
45
|
+
hist = es.history("product", seq)
|
|
46
|
+
print(f"History: {len(hist.get('data', []))} entries")
|
|
47
|
+
|
|
48
|
+
# 삭제
|
|
49
|
+
es.delete("product", seq)
|
|
50
|
+
print("Deleted")
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Server API 클라이언트 (React / 브라우저)
|
|
3
|
+
*
|
|
4
|
+
* 브라우저 환경에서는 HMAC secret을 노출할 수 없으므로 JWT Bearer 토큰 방식을 사용합니다.
|
|
5
|
+
*
|
|
6
|
+
* 패킷 암호화 지원:
|
|
7
|
+
* 서버가 application/octet-stream 으로 응답하면 자동으로 복호화합니다.
|
|
8
|
+
* 복호화 키: sha256(access_token)
|
|
9
|
+
* 의존성: @noble/ciphers, @noble/hashes (npm install @noble/ciphers @noble/hashes)
|
|
10
|
+
*
|
|
11
|
+
* 환경변수 (Vite):
|
|
12
|
+
* VITE_ENTITY_SERVER_URL=http://localhost:47200
|
|
13
|
+
* VITE_PACKET_MAGIC_LEN=4 (서버 packet_magic_len 과 동일하게)
|
|
14
|
+
*
|
|
15
|
+
* 트랜잭션 사용 예:
|
|
16
|
+
* await es.transStart();
|
|
17
|
+
* try {
|
|
18
|
+
* const orderRef = await es.submit('order', { user_seq: 1, total: 9900 }); // seq: "$tx.0"
|
|
19
|
+
* await es.submit('order_item', { order_seq: orderRef.seq, item_seq: 5 }); // "$tx.0" 자동 치환
|
|
20
|
+
* const result = await es.transCommit();
|
|
21
|
+
* const orderSeq = result.results[0].seq; // 실제 seq
|
|
22
|
+
* } catch (e) {
|
|
23
|
+
* await es.transRollback();
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { xchacha20_poly1305 } from "@noble/ciphers/chacha";
|
|
28
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
29
|
+
|
|
30
|
+
export interface EntityListParams {
|
|
31
|
+
page?: number;
|
|
32
|
+
limit?: number;
|
|
33
|
+
orderBy?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EntityQueryFilter {
|
|
37
|
+
field: string;
|
|
38
|
+
op: "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "like" | "in";
|
|
39
|
+
value: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class EntityServerClient {
|
|
43
|
+
private baseUrl: string;
|
|
44
|
+
private token: string;
|
|
45
|
+
private magicLen: number;
|
|
46
|
+
private activeTxId: string | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(baseUrl?: string, token?: string) {
|
|
49
|
+
this.baseUrl = (
|
|
50
|
+
baseUrl ??
|
|
51
|
+
(import.meta as unknown as Record<string, Record<string, string>>)
|
|
52
|
+
.env?.VITE_ENTITY_SERVER_URL ??
|
|
53
|
+
"http://localhost:47200"
|
|
54
|
+
).replace(/\/$/, "");
|
|
55
|
+
this.token = token ?? "";
|
|
56
|
+
const envMagic = (
|
|
57
|
+
import.meta as unknown as Record<string, Record<string, string>>
|
|
58
|
+
).env?.VITE_PACKET_MAGIC_LEN;
|
|
59
|
+
this.magicLen = envMagic ? Number(envMagic) : 4;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setToken(token: string): void {
|
|
63
|
+
this.token = token;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── 인증 ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async login(
|
|
69
|
+
email: string,
|
|
70
|
+
password: string,
|
|
71
|
+
): Promise<{
|
|
72
|
+
access_token: string;
|
|
73
|
+
refresh_token: string;
|
|
74
|
+
expires_in: number;
|
|
75
|
+
}> {
|
|
76
|
+
const data = await this.request<{
|
|
77
|
+
data: {
|
|
78
|
+
access_token: string;
|
|
79
|
+
refresh_token: string;
|
|
80
|
+
expires_in: number;
|
|
81
|
+
};
|
|
82
|
+
}>("POST", "/v1/auth/login", { email, passwd: password }, false);
|
|
83
|
+
this.token = data.data.access_token;
|
|
84
|
+
return data.data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async refreshToken(
|
|
88
|
+
refreshToken: string,
|
|
89
|
+
): Promise<{ access_token: string; expires_in: number }> {
|
|
90
|
+
const data = await this.request<{
|
|
91
|
+
data: { access_token: string; expires_in: number };
|
|
92
|
+
}>("POST", "/v1/auth/refresh", { refresh_token: refreshToken }, false);
|
|
93
|
+
this.token = data.data.access_token;
|
|
94
|
+
return data.data;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── 트랜잭션 ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
|
|
101
|
+
* 이후 submit / delete 가 서버 큐에 쌓이고 transCommit() 시 일괄 처리됩니다.
|
|
102
|
+
*/
|
|
103
|
+
async transStart(): Promise<string> {
|
|
104
|
+
const res = await this.request<{ ok: boolean; transaction_id: string }>(
|
|
105
|
+
"POST",
|
|
106
|
+
"/v1/transaction/start",
|
|
107
|
+
undefined,
|
|
108
|
+
false,
|
|
109
|
+
);
|
|
110
|
+
this.activeTxId = res.transaction_id;
|
|
111
|
+
return this.activeTxId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 트랜잭션 단위로 변경사항을 롤백합니다.
|
|
116
|
+
* transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 사용합니다.
|
|
117
|
+
*/
|
|
118
|
+
transRollback(transactionId?: string): Promise<{ ok: boolean }> {
|
|
119
|
+
const txId = transactionId ?? this.activeTxId;
|
|
120
|
+
if (!txId)
|
|
121
|
+
return Promise.reject(
|
|
122
|
+
new Error("No active transaction. Call transStart() first."),
|
|
123
|
+
);
|
|
124
|
+
this.activeTxId = null;
|
|
125
|
+
return this.request("POST", `/v1/transaction/rollback/${txId}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 트랜잭션 커밋 — 서버 큐에 쌓인 작업을 단일 DB 트랜잭션으로 일괄 처리합니다.
|
|
130
|
+
* transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 사용합니다.
|
|
131
|
+
*/
|
|
132
|
+
transCommit(
|
|
133
|
+
transactionId?: string,
|
|
134
|
+
): Promise<{ ok: boolean; results: unknown[] }> {
|
|
135
|
+
const txId = transactionId ?? this.activeTxId;
|
|
136
|
+
if (!txId)
|
|
137
|
+
return Promise.reject(
|
|
138
|
+
new Error("No active transaction. Call transStart() first."),
|
|
139
|
+
);
|
|
140
|
+
this.activeTxId = null;
|
|
141
|
+
return this.request("POST", `/v1/transaction/commit/${txId}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── CRUD ────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
get<T = unknown>(
|
|
147
|
+
entity: string,
|
|
148
|
+
seq: number,
|
|
149
|
+
): Promise<{ ok: boolean; data: T }> {
|
|
150
|
+
return this.request("GET", `/v1/entity/${entity}/${seq}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
list<T = unknown>(
|
|
154
|
+
entity: string,
|
|
155
|
+
params: EntityListParams = {},
|
|
156
|
+
): Promise<{ ok: boolean; data: T[]; total: number }> {
|
|
157
|
+
const q = buildQuery({ page: 1, limit: 20, ...params });
|
|
158
|
+
return this.request("GET", `/v1/entity/${entity}/list?${q}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
count(entity: string): Promise<{ ok: boolean; count: number }> {
|
|
162
|
+
return this.request("GET", `/v1/entity/${entity}/count`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
query<T = unknown>(
|
|
166
|
+
entity: string,
|
|
167
|
+
filter: EntityQueryFilter[] = [],
|
|
168
|
+
params: EntityListParams = {},
|
|
169
|
+
): Promise<{ ok: boolean; data: T[]; total: number }> {
|
|
170
|
+
const q = buildQuery({ page: 1, limit: 20, ...params });
|
|
171
|
+
return this.request("POST", `/v1/entity/${entity}/query?${q}`, filter);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
submit<T = unknown>(
|
|
175
|
+
entity: string,
|
|
176
|
+
data: Record<string, unknown>,
|
|
177
|
+
opts: { transactionId?: string } = {},
|
|
178
|
+
): Promise<{ ok: boolean; seq?: number; data?: T }> {
|
|
179
|
+
const txId = opts.transactionId ?? this.activeTxId;
|
|
180
|
+
const extra = txId ? { "X-Transaction-ID": txId } : {};
|
|
181
|
+
return this.request(
|
|
182
|
+
"POST",
|
|
183
|
+
`/v1/entity/${entity}/submit`,
|
|
184
|
+
data,
|
|
185
|
+
true,
|
|
186
|
+
extra,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
delete(
|
|
191
|
+
entity: string,
|
|
192
|
+
seq: number,
|
|
193
|
+
opts: { transactionId?: string; hard?: boolean } = {},
|
|
194
|
+
): Promise<{ ok: boolean }> {
|
|
195
|
+
const q = opts.hard ? "?hard=true" : "";
|
|
196
|
+
const txId = opts.transactionId ?? this.activeTxId;
|
|
197
|
+
const extra = txId ? { "X-Transaction-ID": txId } : {};
|
|
198
|
+
return this.request(
|
|
199
|
+
"DELETE",
|
|
200
|
+
`/v1/entity/${entity}/delete/${seq}${q}`,
|
|
201
|
+
undefined,
|
|
202
|
+
true,
|
|
203
|
+
extra,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
history<T = unknown>(
|
|
208
|
+
entity: string,
|
|
209
|
+
seq: number,
|
|
210
|
+
params: EntityListParams = {},
|
|
211
|
+
): Promise<{ ok: boolean; data: T[] }> {
|
|
212
|
+
const q = buildQuery({ page: 1, limit: 50, ...params });
|
|
213
|
+
return this.request("GET", `/v1/entity/${entity}/history/${seq}?${q}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
rollback(entity: string, historySeq: number): Promise<{ ok: boolean }> {
|
|
217
|
+
return this.request(
|
|
218
|
+
"POST",
|
|
219
|
+
`/v1/entity/${entity}/rollback/${historySeq}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async request<T>(
|
|
224
|
+
method: string,
|
|
225
|
+
path: string,
|
|
226
|
+
body?: unknown,
|
|
227
|
+
withAuth = true,
|
|
228
|
+
extraHeaders: Record<string, string> = {},
|
|
229
|
+
): Promise<T> {
|
|
230
|
+
const headers: Record<string, string> = {
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
...extraHeaders,
|
|
233
|
+
};
|
|
234
|
+
if (withAuth && this.token) {
|
|
235
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const res = await fetch(this.baseUrl + path, {
|
|
239
|
+
method,
|
|
240
|
+
headers,
|
|
241
|
+
...(body != null ? { body: JSON.stringify(body) } : {}),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const contentType = res.headers.get("Content-Type") ?? "";
|
|
245
|
+
|
|
246
|
+
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
247
|
+
if (contentType.includes("application/octet-stream")) {
|
|
248
|
+
const buffer = await res.arrayBuffer();
|
|
249
|
+
return this.decryptPacket<T>(buffer);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const data = await res.json();
|
|
253
|
+
if (!data.ok) {
|
|
254
|
+
const err = new Error(
|
|
255
|
+
data.message ?? `EntityServer error (HTTP ${res.status})`,
|
|
256
|
+
);
|
|
257
|
+
(err as { status?: number }).status = res.status;
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
return data as T;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* XChaCha20-Poly1305 패킷 복호화
|
|
265
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
266
|
+
* 키: sha256(access_token)
|
|
267
|
+
*/
|
|
268
|
+
private decryptPacket<T>(buffer: ArrayBuffer): T {
|
|
269
|
+
const key = sha256(new TextEncoder().encode(this.token));
|
|
270
|
+
const data = new Uint8Array(buffer);
|
|
271
|
+
const nonce = data.slice(this.magicLen, this.magicLen + 24);
|
|
272
|
+
const ciphertext = data.slice(this.magicLen + 24);
|
|
273
|
+
const cipher = xchacha20_poly1305(key, nonce);
|
|
274
|
+
const plaintext = cipher.decrypt(ciphertext);
|
|
275
|
+
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildQuery(params: Record<string, unknown>): string {
|
|
280
|
+
return Object.entries(params)
|
|
281
|
+
.filter(([, v]) => v != null)
|
|
282
|
+
.map(
|
|
283
|
+
([k, v]) =>
|
|
284
|
+
`${encodeURIComponent(k === "orderBy" ? "order_by" : k)}=${encodeURIComponent(String(v))}`,
|
|
285
|
+
)
|
|
286
|
+
.join("&");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 싱글턴 인스턴스 (앱 전체 공유) */
|
|
290
|
+
export const entityServer = new EntityServerClient();
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React 컴포넌트 사용 예제
|
|
3
|
+
*
|
|
4
|
+
* 설정:
|
|
5
|
+
* 1. .env 에 VITE_ENTITY_SERVER_URL=http://localhost:47200 추가
|
|
6
|
+
* 2. 로그인 시 entityServer.login(email, password) 호출
|
|
7
|
+
* 3. 이후 훅으로 데이터 조회/수정
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
useEntityDelete,
|
|
13
|
+
useEntityGet,
|
|
14
|
+
useEntityList,
|
|
15
|
+
useEntitySubmit,
|
|
16
|
+
} from "./hooks/useEntity";
|
|
17
|
+
|
|
18
|
+
interface Product {
|
|
19
|
+
seq: number;
|
|
20
|
+
name: string;
|
|
21
|
+
price: number;
|
|
22
|
+
category: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── 목록 컴포넌트 ─────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export function ProductList() {
|
|
28
|
+
const [page, setPage] = useState(1);
|
|
29
|
+
const { data, isLoading, error } = useEntityList<Product>("product", {
|
|
30
|
+
page,
|
|
31
|
+
limit: 20,
|
|
32
|
+
});
|
|
33
|
+
const deleteMut = useEntityDelete("product");
|
|
34
|
+
|
|
35
|
+
if (isLoading) return <p>로딩 중...</p>;
|
|
36
|
+
if (error) return <p>에러: {(error as Error).message}</p>;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
<h2>상품 목록 ({data?.total ?? 0}건)</h2>
|
|
41
|
+
<ul>
|
|
42
|
+
{data?.data.map((item) => (
|
|
43
|
+
<li key={item.seq}>
|
|
44
|
+
[{item.seq}] {item.name} — {item.price.toLocaleString()}
|
|
45
|
+
원
|
|
46
|
+
<button
|
|
47
|
+
onClick={() => deleteMut.mutate(item.seq)}
|
|
48
|
+
disabled={deleteMut.isPending}
|
|
49
|
+
>
|
|
50
|
+
삭제
|
|
51
|
+
</button>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
57
|
+
disabled={page === 1}
|
|
58
|
+
>
|
|
59
|
+
이전
|
|
60
|
+
</button>
|
|
61
|
+
<span> {page} </span>
|
|
62
|
+
<button onClick={() => setPage((p) => p + 1)}>다음</button>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── 단건 조회 컴포넌트 ─────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export function ProductDetail({ seq }: { seq: number }) {
|
|
70
|
+
const { data, isLoading } = useEntityGet<Product>("product", seq);
|
|
71
|
+
|
|
72
|
+
if (isLoading) return <p>로딩 중...</p>;
|
|
73
|
+
const item = data?.data;
|
|
74
|
+
if (!item) return <p>상품을 찾을 수 없습니다.</p>;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div>
|
|
78
|
+
<h3>{item.name}</h3>
|
|
79
|
+
<p>가격: {item.price.toLocaleString()}원</p>
|
|
80
|
+
<p>카테고리: {item.category}</p>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── 생성/수정 폼 컴포넌트 ──────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export function ProductForm({ seq }: { seq?: number }) {
|
|
88
|
+
const submitMut = useEntitySubmit("product");
|
|
89
|
+
const [form, setForm] = useState({ name: "", price: 0, category: "" });
|
|
90
|
+
|
|
91
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
// seq 있으면 수정, 없으면 생성
|
|
94
|
+
await submitMut.mutateAsync(seq ? { ...form, seq } : form);
|
|
95
|
+
alert(seq ? "수정 완료" : "등록 완료");
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<form onSubmit={handleSubmit}>
|
|
100
|
+
<input
|
|
101
|
+
placeholder="상품명"
|
|
102
|
+
value={form.name}
|
|
103
|
+
onChange={(e) =>
|
|
104
|
+
setForm((f) => ({ ...f, name: e.target.value }))
|
|
105
|
+
}
|
|
106
|
+
/>
|
|
107
|
+
<input
|
|
108
|
+
type="number"
|
|
109
|
+
placeholder="가격"
|
|
110
|
+
value={form.price}
|
|
111
|
+
onChange={(e) =>
|
|
112
|
+
setForm((f) => ({ ...f, price: +e.target.value }))
|
|
113
|
+
}
|
|
114
|
+
/>
|
|
115
|
+
<input
|
|
116
|
+
placeholder="카테고리"
|
|
117
|
+
value={form.category}
|
|
118
|
+
onChange={(e) =>
|
|
119
|
+
setForm((f) => ({ ...f, category: e.target.value }))
|
|
120
|
+
}
|
|
121
|
+
/>
|
|
122
|
+
<button type="submit" disabled={submitMut.isPending}>
|
|
123
|
+
{seq ? "수정" : "등록"}
|
|
124
|
+
</button>
|
|
125
|
+
</form>
|
|
126
|
+
);
|
|
127
|
+
}
|