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,260 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace App\Libraries;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Entity Server 클라이언트 라이브러리 (CodeIgniter 4)
|
|
7
|
+
*
|
|
8
|
+
* 필요 확장: ext-sodium (PHP 7.2+ 기본 내장) — XChaCha20-Poly1305 복호화
|
|
9
|
+
*
|
|
10
|
+
* 설치: app/Libraries/EntityServer.php 에 배치
|
|
11
|
+
*
|
|
12
|
+
* 설정: app/Config/EntityServer.php 또는 .env 에서
|
|
13
|
+
* ENTITY_SERVER_URL=http://localhost:47200
|
|
14
|
+
* ENTITY_SERVER_API_KEY=your-api-key
|
|
15
|
+
* ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
|
|
16
|
+
* ENTITY_PACKET_MAGIC_LEN=4
|
|
17
|
+
*
|
|
18
|
+
* 컨트롤러 사용법:
|
|
19
|
+
* $es = new \App\Libraries\EntityServer();
|
|
20
|
+
* $result = $es->get('account', 1);
|
|
21
|
+
* $list = $es->list('account', ['page' => 1, 'limit' => 20]);
|
|
22
|
+
* $seq = $es->submit('account', ['name' => '홍길동', 'email' => 'hong@example.com']);
|
|
23
|
+
*/
|
|
24
|
+
class EntityServer
|
|
25
|
+
{
|
|
26
|
+
private string $baseUrl;
|
|
27
|
+
private string $apiKey;
|
|
28
|
+
private string $hmacSecret;
|
|
29
|
+
private int $timeout;
|
|
30
|
+
private int $magicLen;
|
|
31
|
+
private ?string $activeTxId = null;
|
|
32
|
+
|
|
33
|
+
public function __construct(
|
|
34
|
+
string $baseUrl = '',
|
|
35
|
+
string $apiKey = '',
|
|
36
|
+
string $hmacSecret = '',
|
|
37
|
+
int $timeout = 10,
|
|
38
|
+
int $magicLen = 4
|
|
39
|
+
) {
|
|
40
|
+
$this->baseUrl = rtrim($baseUrl ?: env('ENTITY_SERVER_URL', 'http://localhost:47200'), '/');
|
|
41
|
+
$this->apiKey = $apiKey ?: env('ENTITY_SERVER_API_KEY', '');
|
|
42
|
+
$this->hmacSecret = $hmacSecret ?: env('ENTITY_SERVER_HMAC_SECRET', '');
|
|
43
|
+
$this->timeout = $timeout;
|
|
44
|
+
$this->magicLen = (int) ($magicLen ?: env('ENTITY_PACKET_MAGIC_LEN', 4));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── CRUD ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** 단건 조회 */
|
|
50
|
+
public function get(string $entity, int $seq): array
|
|
51
|
+
{
|
|
52
|
+
return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 목록 조회 */
|
|
56
|
+
public function list(string $entity, array $params = []): array
|
|
57
|
+
{
|
|
58
|
+
$query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
|
|
59
|
+
return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 건수 조회 */
|
|
63
|
+
public function count(string $entity): array
|
|
64
|
+
{
|
|
65
|
+
return $this->request('GET', "/v1/entity/{$entity}/count");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 필터 검색
|
|
70
|
+
*
|
|
71
|
+
* @param array $filter 예: [['field' => 'status', 'op' => 'eq', 'value' => 'active']]
|
|
72
|
+
* @param array $params 예: ['page' => 1, 'limit' => 20, 'order_by' => 'name']
|
|
73
|
+
*/
|
|
74
|
+
public function query(string $entity, array $filter = [], array $params = []): array
|
|
75
|
+
{
|
|
76
|
+
$query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
|
|
77
|
+
return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
|
|
82
|
+
* 이후 submit / delete 가 실제 실행되지 않고 서버 큐에 쌓입니다.
|
|
83
|
+
* transCommit() 시 한 번에 DB 트랜잭션으로 실행됩니다.
|
|
84
|
+
*/
|
|
85
|
+
public function transStart(): string
|
|
86
|
+
{
|
|
87
|
+
$result = $this->request('POST', '/v1/transaction/start');
|
|
88
|
+
$this->activeTxId = $result['transaction_id'];
|
|
89
|
+
return $this->activeTxId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 트랜잭션 전체 롤백
|
|
94
|
+
* $transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 롤백합니다.
|
|
95
|
+
*/
|
|
96
|
+
public function transRollback(?string $transactionId = null): array
|
|
97
|
+
{
|
|
98
|
+
$txId = $transactionId ?? $this->activeTxId;
|
|
99
|
+
if ($txId === null) {
|
|
100
|
+
throw new \RuntimeException('No active transaction. Call transStart() first.');
|
|
101
|
+
}
|
|
102
|
+
$this->activeTxId = null;
|
|
103
|
+
return $this->request('POST', "/v1/transaction/rollback/{$txId}");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 트랜잭션 커밋 — 큐에 쌓인 모든 작업을 단일 DB 트랜잭션으로 일괄 실행합니다.
|
|
108
|
+
* 하나라도 실패하면 전체가 ROLLBACK 됩니다.
|
|
109
|
+
*/
|
|
110
|
+
public function transCommit(): array
|
|
111
|
+
{
|
|
112
|
+
$txId = $this->activeTxId;
|
|
113
|
+
if ($txId === null) {
|
|
114
|
+
throw new \RuntimeException('No active transaction. Call transStart() first.');
|
|
115
|
+
}
|
|
116
|
+
$this->activeTxId = null;
|
|
117
|
+
return $this->request('POST', "/v1/transaction/commit/{$txId}");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 생성 또는 수정
|
|
122
|
+
* - body에 'seq' 포함 → 수정
|
|
123
|
+
* - body에 'seq' 없음 → 생성 (seq 반환)
|
|
124
|
+
* @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
125
|
+
*/
|
|
126
|
+
public function submit(string $entity, array $data, ?string $transactionId = null): array
|
|
127
|
+
{
|
|
128
|
+
$txId = $transactionId ?? $this->activeTxId;
|
|
129
|
+
$extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
|
|
130
|
+
return $this->request('POST', "/v1/entity/{$entity}/submit", $data, $extra);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 삭제
|
|
135
|
+
* @param bool $hard true 이면 물리 삭제
|
|
136
|
+
* @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
137
|
+
*/
|
|
138
|
+
public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
|
|
139
|
+
{
|
|
140
|
+
$q = $hard ? '?hard=true' : '';
|
|
141
|
+
$txId = $transactionId ?? $this->activeTxId;
|
|
142
|
+
$extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
|
|
143
|
+
return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 변경 이력 조회 */
|
|
147
|
+
public function history(string $entity, int $seq, int $page = 1, int $limit = 50): array
|
|
148
|
+
{
|
|
149
|
+
return $this->request('GET', "/v1/entity/{$entity}/history/{$seq}?page={$page}&limit={$limit}");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 트랜잭션 롤백 */
|
|
153
|
+
public function rollback(string $entity, int $historySeq): array
|
|
154
|
+
{
|
|
155
|
+
return $this->request('POST', "/v1/entity/{$entity}/rollback/{$historySeq}");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
|
|
161
|
+
{
|
|
162
|
+
$bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
|
|
163
|
+
$timestamp = (string) time();
|
|
164
|
+
$nonce = $this->generateNonce();
|
|
165
|
+
$signature = $this->sign($method, $path, $timestamp, $nonce, $bodyJson);
|
|
166
|
+
|
|
167
|
+
$headers = array_merge([
|
|
168
|
+
'Content-Type: application/json',
|
|
169
|
+
'X-API-Key: ' . $this->apiKey,
|
|
170
|
+
'X-Timestamp: ' . $timestamp,
|
|
171
|
+
'X-Nonce: ' . $nonce,
|
|
172
|
+
'X-Signature: ' . $signature,
|
|
173
|
+
], $extraHeaders);
|
|
174
|
+
|
|
175
|
+
$url = $this->baseUrl . $path;
|
|
176
|
+
$ch = curl_init($url);
|
|
177
|
+
|
|
178
|
+
curl_setopt_array($ch, [
|
|
179
|
+
CURLOPT_CUSTOMREQUEST => $method,
|
|
180
|
+
CURLOPT_HTTPHEADER => $headers,
|
|
181
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
182
|
+
CURLOPT_TIMEOUT => $this->timeout,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
if ($bodyJson !== '') {
|
|
186
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
$response = curl_exec($ch);
|
|
190
|
+
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
191
|
+
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
|
|
192
|
+
$error = curl_error($ch);
|
|
193
|
+
curl_close($ch);
|
|
194
|
+
|
|
195
|
+
if ($error) {
|
|
196
|
+
throw new \RuntimeException("EntityServer curl error: {$error}");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
200
|
+
if (str_contains($contentType, 'application/octet-stream')) {
|
|
201
|
+
$jsonStr = $this->decryptPacket($response);
|
|
202
|
+
$decoded = json_decode($jsonStr, true);
|
|
203
|
+
} else {
|
|
204
|
+
$decoded = json_decode($response, true);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if ($decoded === null) {
|
|
208
|
+
throw new \RuntimeException("EntityServer invalid JSON response (HTTP {$httpCode})");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!($decoded['ok'] ?? false)) {
|
|
212
|
+
throw new \RuntimeException("EntityServer error: " . ($decoded['message'] ?? 'Unknown') . " (HTTP {$httpCode})");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return $decoded;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* XChaCha20-Poly1305 패킷 복호화
|
|
220
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
221
|
+
* 키: sha256(hmac_secret)
|
|
222
|
+
*
|
|
223
|
+
* ext-sodium 사용 (PHP 7.2+ 내장)
|
|
224
|
+
*/
|
|
225
|
+
private function decryptPacket(string $data): string
|
|
226
|
+
{
|
|
227
|
+
$key = hash('sha256', $this->hmacSecret, true);
|
|
228
|
+
$nonce = substr($data, $this->magicLen, 24);
|
|
229
|
+
$ciphertext = substr($data, $this->magicLen + 24);
|
|
230
|
+
|
|
231
|
+
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
|
|
232
|
+
if ($plaintext === false) {
|
|
233
|
+
throw new \RuntimeException('Packet decryption failed: authentication tag mismatch');
|
|
234
|
+
}
|
|
235
|
+
return $plaintext;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** HMAC-SHA256 서명 */
|
|
239
|
+
private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
|
|
240
|
+
{
|
|
241
|
+
// PATH는 쿼리스트링 포함한 전체 경로
|
|
242
|
+
$payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
|
|
243
|
+
return hash_hmac('sha256', $payload, $this->hmacSecret);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private function generateNonce(): string
|
|
247
|
+
{
|
|
248
|
+
return sprintf(
|
|
249
|
+
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
250
|
+
mt_rand(0, 0xffff),
|
|
251
|
+
mt_rand(0, 0xffff),
|
|
252
|
+
mt_rand(0, 0xffff),
|
|
253
|
+
mt_rand(0, 0x0fff) | 0x4000,
|
|
254
|
+
mt_rand(0, 0x3fff) | 0x8000,
|
|
255
|
+
mt_rand(0, 0xffff),
|
|
256
|
+
mt_rand(0, 0xffff),
|
|
257
|
+
mt_rand(0, 0xffff)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace App\Http\Controllers;
|
|
4
|
+
|
|
5
|
+
use App\Services\EntityServerService;
|
|
6
|
+
use Illuminate\Http\JsonResponse;
|
|
7
|
+
use Illuminate\Http\Request;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* EntityServerService를 사용하는 Laravel 컨트롤러 예시
|
|
11
|
+
*/
|
|
12
|
+
class ProductController extends Controller
|
|
13
|
+
{
|
|
14
|
+
public function __construct(private EntityServerService $es) {}
|
|
15
|
+
|
|
16
|
+
/** GET /api/products */
|
|
17
|
+
public function index(Request $request): JsonResponse
|
|
18
|
+
{
|
|
19
|
+
$result = $this->es->list('product', [
|
|
20
|
+
'page' => $request->integer('page', 1),
|
|
21
|
+
'limit' => $request->integer('limit', 20),
|
|
22
|
+
]);
|
|
23
|
+
return response()->json($result);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** GET /api/products/{seq} */
|
|
27
|
+
public function show(int $seq): JsonResponse
|
|
28
|
+
{
|
|
29
|
+
return response()->json($this->es->get('product', $seq));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** POST /api/products/search */
|
|
33
|
+
public function search(Request $request): JsonResponse
|
|
34
|
+
{
|
|
35
|
+
$result = $this->es->query(
|
|
36
|
+
'product',
|
|
37
|
+
$request->input('filter', []),
|
|
38
|
+
['page' => $request->integer('page', 1), 'limit' => $request->integer('limit', 20)]
|
|
39
|
+
);
|
|
40
|
+
return response()->json($result);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** POST /api/products */
|
|
44
|
+
public function store(Request $request): JsonResponse
|
|
45
|
+
{
|
|
46
|
+
$result = $this->es->submit('product', $request->all());
|
|
47
|
+
return response()->json($result, 201);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** PUT /api/products/{seq} */
|
|
51
|
+
public function update(Request $request, int $seq): JsonResponse
|
|
52
|
+
{
|
|
53
|
+
$result = $this->es->submit('product', array_merge($request->all(), ['seq' => $seq]));
|
|
54
|
+
return response()->json($result);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** DELETE /api/products/{seq} */
|
|
58
|
+
public function destroy(int $seq): JsonResponse
|
|
59
|
+
{
|
|
60
|
+
return response()->json($this->es->delete('product', $seq));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace App\Services;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Http;
|
|
6
|
+
use Illuminate\Support\Str;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Entity Server 클라이언트 서비스 (Laravel)
|
|
10
|
+
*
|
|
11
|
+
* 필요 확장: ext-sodium (PHP 7.2+ 기본 내장) — XChaCha20-Poly1305 복호화
|
|
12
|
+
*
|
|
13
|
+
* 설정: config/services.php 또는 .env
|
|
14
|
+
* ENTITY_SERVER_URL=http://localhost:47200
|
|
15
|
+
* ENTITY_SERVER_API_KEY=your-api-key
|
|
16
|
+
* ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
|
|
17
|
+
* ENTITY_PACKET_MAGIC_LEN=4
|
|
18
|
+
*
|
|
19
|
+
* 서비스 프로바이더 등록:
|
|
20
|
+
* $this->app->singleton(EntityServerService::class);
|
|
21
|
+
*
|
|
22
|
+
* 컨트롤러 사용법:
|
|
23
|
+
* public function __construct(private EntityServerService $es) {}
|
|
24
|
+
* $result = $this->es->get('account', 1);
|
|
25
|
+
*
|
|
26
|
+
* 트랜잭션 사용 예:
|
|
27
|
+
* $es->transStart();
|
|
28
|
+
* try {
|
|
29
|
+
* $orderRef = $es->submit('order', ['user_seq' => 1, 'total' => 9900]); // seq: "$tx.0"
|
|
30
|
+
* $es->submit('order_item', ['order_seq' => $orderRef['seq'], 'item_seq' => 5]); // "$tx.0" 자동 치환
|
|
31
|
+
* $result = $es->transCommit();
|
|
32
|
+
* $orderSeq = $result['results'][0]['seq']; // 실제 seq
|
|
33
|
+
* } catch (\Throwable $e) {
|
|
34
|
+
* $es->transRollback();
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
class EntityServerService
|
|
38
|
+
{
|
|
39
|
+
private string $baseUrl;
|
|
40
|
+
private string $apiKey;
|
|
41
|
+
private string $hmacSecret;
|
|
42
|
+
private int $magicLen;
|
|
43
|
+
private ?string $activeTxId = null;
|
|
44
|
+
|
|
45
|
+
public function __construct()
|
|
46
|
+
{
|
|
47
|
+
$this->baseUrl = rtrim(config('services.entity_server.url', env('ENTITY_SERVER_URL', 'http://localhost:47200')), '/');
|
|
48
|
+
$this->apiKey = config('services.entity_server.api_key', env('ENTITY_SERVER_API_KEY', ''));
|
|
49
|
+
$this->hmacSecret = config('services.entity_server.hmac_secret', env('ENTITY_SERVER_HMAC_SECRET', ''));
|
|
50
|
+
$this->magicLen = (int) config('services.entity_server.packet_magic_len', env('ENTITY_PACKET_MAGIC_LEN', 4));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── CRUD ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
public function get(string $entity, int $seq): array
|
|
56
|
+
{
|
|
57
|
+
return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public function list(string $entity, array $params = []): array
|
|
61
|
+
{
|
|
62
|
+
$query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
|
|
63
|
+
return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public function count(string $entity): array
|
|
67
|
+
{
|
|
68
|
+
return $this->request('GET', "/v1/entity/{$entity}/count");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public function query(string $entity, array $filter = [], array $params = []): array
|
|
72
|
+
{
|
|
73
|
+
$query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
|
|
74
|
+
return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
|
|
79
|
+
* 이후 submit / delete 가 실제 실행되지 않고 서버 큐에 쌓입니다.
|
|
80
|
+
* transCommit() 시 한 번에 DB 트랜잭션으로 실행됩니다.
|
|
81
|
+
*/
|
|
82
|
+
public function transStart(): string
|
|
83
|
+
{
|
|
84
|
+
$result = $this->request('POST', '/v1/transaction/start');
|
|
85
|
+
$this->activeTxId = $result['transaction_id'];
|
|
86
|
+
return $this->activeTxId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 트랜잭션 전체 롤백
|
|
91
|
+
* $transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 롤백합니다.
|
|
92
|
+
*/
|
|
93
|
+
public function transRollback(?string $transactionId = null): array
|
|
94
|
+
{
|
|
95
|
+
$txId = $transactionId ?? $this->activeTxId;
|
|
96
|
+
if ($txId === null) {
|
|
97
|
+
throw new \RuntimeException('No active transaction. Call transStart() first.');
|
|
98
|
+
}
|
|
99
|
+
$this->activeTxId = null;
|
|
100
|
+
return $this->request('POST', "/v1/transaction/rollback/{$txId}");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 트랜잭션 커밋 — 큐에 쌓인 모든 작업을 단일 DB 트랜잭션으로 일괄 실행합니다. */
|
|
104
|
+
public function transCommit(): array
|
|
105
|
+
{
|
|
106
|
+
$txId = $this->activeTxId;
|
|
107
|
+
if ($txId === null) {
|
|
108
|
+
throw new \RuntimeException('No active transaction. Call transStart() first.');
|
|
109
|
+
}
|
|
110
|
+
$this->activeTxId = null;
|
|
111
|
+
return $this->request('POST', "/v1/transaction/commit/{$txId}");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
|
|
115
|
+
public function submit(string $entity, array $data, ?string $transactionId = null): array
|
|
116
|
+
{
|
|
117
|
+
$txId = $transactionId ?? $this->activeTxId;
|
|
118
|
+
$extra = $txId ? ['X-Transaction-ID' => $txId] : [];
|
|
119
|
+
return $this->request('POST', "/v1/entity/{$entity}/submit", $data, $extra);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 삭제 */
|
|
123
|
+
public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
|
|
124
|
+
{
|
|
125
|
+
$q = $hard ? '?hard=true' : '';
|
|
126
|
+
$txId = $transactionId ?? $this->activeTxId;
|
|
127
|
+
$extra = $txId ? ['X-Transaction-ID' => $txId] : [];
|
|
128
|
+
return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public function history(string $entity, int $seq, int $page = 1, int $limit = 50): array
|
|
132
|
+
{
|
|
133
|
+
return $this->request('GET', "/v1/entity/{$entity}/history/{$seq}?page={$page}&limit={$limit}");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public function rollback(string $entity, int $historySeq): array
|
|
137
|
+
{
|
|
138
|
+
return $this->request('POST', "/v1/entity/{$entity}/rollback/{$historySeq}");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
|
|
144
|
+
{
|
|
145
|
+
$bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
|
|
146
|
+
$timestamp = (string) time();
|
|
147
|
+
$nonce = (string) Str::uuid();
|
|
148
|
+
$signature = $this->sign($method, $path, $timestamp, $nonce, $bodyJson);
|
|
149
|
+
|
|
150
|
+
$http = Http::withHeaders(array_merge([
|
|
151
|
+
'X-API-Key' => $this->apiKey,
|
|
152
|
+
'X-Timestamp' => $timestamp,
|
|
153
|
+
'X-Nonce' => $nonce,
|
|
154
|
+
'X-Signature' => $signature,
|
|
155
|
+
], $extraHeaders))->timeout(10);
|
|
156
|
+
|
|
157
|
+
$response = match ($method) {
|
|
158
|
+
'GET' => $http->get($this->baseUrl . $path),
|
|
159
|
+
'POST' => $http->withBody($bodyJson, 'application/json')->post($this->baseUrl . $path),
|
|
160
|
+
'DELETE' => $http->delete($this->baseUrl . $path),
|
|
161
|
+
default => throw new \InvalidArgumentException("Unsupported method: {$method}"),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
$decoded = $response->json();
|
|
165
|
+
|
|
166
|
+
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
167
|
+
$contentType = $response->header('Content-Type') ?? '';
|
|
168
|
+
if (str_contains($contentType, 'application/octet-stream')) {
|
|
169
|
+
$jsonStr = $this->decryptPacket($response->body());
|
|
170
|
+
$decoded = json_decode($jsonStr, true);
|
|
171
|
+
} else {
|
|
172
|
+
$decoded = $response->json();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!($decoded['ok'] ?? false)) {
|
|
176
|
+
throw new \RuntimeException(
|
|
177
|
+
'EntityServer error: ' . ($decoded['message'] ?? 'Unknown') .
|
|
178
|
+
' (HTTP ' . $response->status() . ')'
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return $decoded;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* XChaCha20-Poly1305 패킷 복호화
|
|
187
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
188
|
+
* 키: sha256(hmac_secret)
|
|
189
|
+
*
|
|
190
|
+
* ext-sodium 사용 (PHP 7.2+ 내장)
|
|
191
|
+
*/
|
|
192
|
+
private function decryptPacket(string $data): string
|
|
193
|
+
{
|
|
194
|
+
$key = hash('sha256', $this->hmacSecret, true);
|
|
195
|
+
$nonce = substr($data, $this->magicLen, 24);
|
|
196
|
+
$ciphertext = substr($data, $this->magicLen + 24);
|
|
197
|
+
|
|
198
|
+
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
|
|
199
|
+
if ($plaintext === false) {
|
|
200
|
+
throw new \RuntimeException('Packet decryption failed: authentication tag mismatch');
|
|
201
|
+
}
|
|
202
|
+
return $plaintext;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
|
|
206
|
+
{
|
|
207
|
+
$payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
|
|
208
|
+
return hash_hmac('sha256', $payload, $this->hmacSecret);
|
|
209
|
+
}
|
|
210
|
+
}
|