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,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
+ }