create-entity-server 0.0.9 → 0.0.23
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 +26 -8
- package/package.json +1 -1
- package/template/.env.example +20 -3
- package/template/configs/database.json +173 -10
- package/template/configs/jwt.json +1 -0
- package/template/configs/oauth.json +37 -0
- package/template/configs/push.json +26 -0
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/README.md +4 -4
- package/template/entities/{Auth → System/Auth}/account.json +0 -14
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +43 -21
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/01_basic_fields.json +39 -0
- package/template/samples/entities/02_types_and_defaults.json +67 -0
- package/template/samples/entities/03_hash_and_unique.json +33 -0
- package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
- package/template/samples/entities/05_cache.json +55 -0
- package/template/samples/entities/06_history_and_hard_delete.json +60 -0
- package/template/samples/entities/07_license_scope.json +52 -0
- package/template/samples/entities/08_hook_sql.json +52 -0
- package/template/samples/entities/09_hook_entity.json +65 -0
- package/template/samples/entities/10_hook_submit_delete.json +78 -0
- package/template/samples/entities/11_hook_webhook.json +84 -0
- package/template/samples/entities/12_hook_push.json +73 -0
- package/template/samples/entities/13_read_only.json +54 -0
- package/template/samples/entities/14_optimistic_lock.json +29 -0
- package/template/samples/entities/15_reset_defaults.json +94 -0
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +91 -0
- package/template/samples/flutter/lib/entity_server_client.dart +261 -48
- package/template/samples/java/EntityServerClient.java +325 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +261 -45
- package/template/samples/node/src/EntityServerClient.js +348 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +14 -0
- package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
- package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
- package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
- package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
- package/template/samples/python/entity_server.py +287 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +248 -37
- package/template/scripts/normalize-entities.sh +10 -10
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +120 -37
- package/template/scripts/update-server.ps1 +160 -4
- package/template/scripts/update-server.sh +132 -4
- package/template/samples/react/src/api/entityServerClient.ts +0 -290
- package/template/samples/react/src/hooks/useEntity.ts +0 -105
- /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
- /package/template/entities/{Auth → System/Auth}/license.json +0 -0
- /package/template/entities/{Auth → System/Auth}/rbac_roles.json +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "country",
|
|
3
|
+
"description": "reset_defaults 예제 — reset-all 실행 시 주요 국가 코드 자동 시딩",
|
|
4
|
+
"index": {
|
|
5
|
+
"code": {
|
|
6
|
+
"comment": "ISO 3166-1 alpha-2 국가 코드 (예: KR, US)",
|
|
7
|
+
"required": true,
|
|
8
|
+
"unique": true
|
|
9
|
+
},
|
|
10
|
+
"name_ko": {
|
|
11
|
+
"comment": "국가명 (한국어)"
|
|
12
|
+
},
|
|
13
|
+
"name_en": {
|
|
14
|
+
"comment": "국가명 (영어)"
|
|
15
|
+
},
|
|
16
|
+
"region": {
|
|
17
|
+
"comment": "대륙/지역",
|
|
18
|
+
"type": [
|
|
19
|
+
"asia",
|
|
20
|
+
"europe",
|
|
21
|
+
"americas",
|
|
22
|
+
"africa",
|
|
23
|
+
"oceania",
|
|
24
|
+
"middle_east"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"phone_code": {
|
|
28
|
+
"comment": "국가 전화 코드 (예: +82)"
|
|
29
|
+
},
|
|
30
|
+
"is_active": {
|
|
31
|
+
"comment": "서비스 지원 여부 (is_* → TINYINT(1) 자동 추론)",
|
|
32
|
+
"default": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"hard_delete": true,
|
|
36
|
+
"reset_defaults": [
|
|
37
|
+
{
|
|
38
|
+
"code": "KR",
|
|
39
|
+
"name_ko": "대한민국",
|
|
40
|
+
"name_en": "South Korea",
|
|
41
|
+
"region": "asia",
|
|
42
|
+
"phone_code": "+82",
|
|
43
|
+
"is_active": true
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"code": "US",
|
|
47
|
+
"name_ko": "미국",
|
|
48
|
+
"name_en": "United States",
|
|
49
|
+
"region": "americas",
|
|
50
|
+
"phone_code": "+1",
|
|
51
|
+
"is_active": true
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"code": "JP",
|
|
55
|
+
"name_ko": "일본",
|
|
56
|
+
"name_en": "Japan",
|
|
57
|
+
"region": "asia",
|
|
58
|
+
"phone_code": "+81",
|
|
59
|
+
"is_active": true
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"code": "CN",
|
|
63
|
+
"name_ko": "중국",
|
|
64
|
+
"name_en": "China",
|
|
65
|
+
"region": "asia",
|
|
66
|
+
"phone_code": "+86",
|
|
67
|
+
"is_active": true
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"code": "DE",
|
|
71
|
+
"name_ko": "독일",
|
|
72
|
+
"name_en": "Germany",
|
|
73
|
+
"region": "europe",
|
|
74
|
+
"phone_code": "+49",
|
|
75
|
+
"is_active": true
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"code": "GB",
|
|
79
|
+
"name_ko": "영국",
|
|
80
|
+
"name_en": "United Kingdom",
|
|
81
|
+
"region": "europe",
|
|
82
|
+
"phone_code": "+44",
|
|
83
|
+
"is_active": true
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"code": "SG",
|
|
87
|
+
"name_ko": "싱가포르",
|
|
88
|
+
"name_en": "Singapore",
|
|
89
|
+
"region": "asia",
|
|
90
|
+
"phone_code": "+65",
|
|
91
|
+
"is_active": true
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "organization",
|
|
3
|
+
"description": "isolated: license 예제 — 이 엔티티가 테넌트(라이선스) 경계를 정의하는 루트 엔티티. license_seq FK 없이 license_seq 컬럼을 직접 소유",
|
|
4
|
+
"isolated": "license",
|
|
5
|
+
"index": {
|
|
6
|
+
"name": {
|
|
7
|
+
"comment": "조직명",
|
|
8
|
+
"required": true,
|
|
9
|
+
"unique": true
|
|
10
|
+
},
|
|
11
|
+
"plan": {
|
|
12
|
+
"comment": "구독 플랜",
|
|
13
|
+
"type": ["free", "starter", "pro", "enterprise"],
|
|
14
|
+
"default": "free"
|
|
15
|
+
},
|
|
16
|
+
"max_members": {
|
|
17
|
+
"comment": "최대 팀원 수",
|
|
18
|
+
"type": "uint",
|
|
19
|
+
"default": 5
|
|
20
|
+
},
|
|
21
|
+
"owner_seq": {
|
|
22
|
+
"comment": "소유자 account seq"
|
|
23
|
+
},
|
|
24
|
+
"is_active": {
|
|
25
|
+
"comment": "활성 여부 (is_* → TINYINT(1) 자동 추론)",
|
|
26
|
+
"default": true
|
|
27
|
+
},
|
|
28
|
+
"expires_at": {
|
|
29
|
+
"comment": "구독 만료일시 (*_at → DATETIME 자동 추론)"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"fields": {
|
|
33
|
+
"billing_email": {
|
|
34
|
+
"type": "varchar(255)",
|
|
35
|
+
"comment": "청구용 이메일"
|
|
36
|
+
},
|
|
37
|
+
"settings": {
|
|
38
|
+
"comment": "조직 설정 (중첩 fields 그룹)",
|
|
39
|
+
"fields": {
|
|
40
|
+
"theme": {
|
|
41
|
+
"type": ["light", "dark", "system"],
|
|
42
|
+
"comment": "기본 UI 테마",
|
|
43
|
+
"default": "system"
|
|
44
|
+
},
|
|
45
|
+
"language": {
|
|
46
|
+
"type": "varchar(10)",
|
|
47
|
+
"comment": "기본 인터페이스 언어",
|
|
48
|
+
"default": "ko"
|
|
49
|
+
},
|
|
50
|
+
"timezone": {
|
|
51
|
+
"type": "varchar(50)",
|
|
52
|
+
"comment": "기본 타임존",
|
|
53
|
+
"default": "Asia/Seoul"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"cache": {
|
|
59
|
+
"enabled": true,
|
|
60
|
+
"ttl_seconds": 300
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# 엔티티 설정 예제
|
|
2
|
+
|
|
3
|
+
`entities/` 디렉토리에 배치하는 `.json` 설정 파일 예제 모음입니다.
|
|
4
|
+
엔티티 서버의 **모든 주요 기능을 하나씩** 알아볼 수 있도록 주제별로 구성하였습니다.
|
|
5
|
+
|
|
6
|
+
> **전체 레퍼런스**: [docs/ops/entity-config-guide.md](../../docs/ops/entity-config-guide.md)
|
|
7
|
+
> **훅 가이드**: [docs/ops/hooks.md](../../docs/ops/hooks.md)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 기능별 샘플 목록
|
|
12
|
+
|
|
13
|
+
### 기본 필드 & 타입
|
|
14
|
+
|
|
15
|
+
| 파일 | 엔티티 | 설명 |
|
|
16
|
+
| -------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| [01_basic_fields.json](01_basic_fields.json) | `contact` | 필드명 패턴으로 타입이 **자동 추론**되는 모든 케이스 (`_seq`, `_date`, `_at`, `is_*`, `_count`, `_amount`, `email`, `phone`) |
|
|
18
|
+
| [02_types_and_defaults.json](02_types_and_defaults.json) | `product` | 자동 추론이 안 될 때 **`fields` 명시 선언** (`type`, `comment`, `default` 인라인) + `reset_defaults` 시딩 |
|
|
19
|
+
|
|
20
|
+
### 제약 & 참조
|
|
21
|
+
|
|
22
|
+
| 파일 | 엔티티 | 설명 |
|
|
23
|
+
| ------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------ |
|
|
24
|
+
| [03_hash_and_unique.json](03_hash_and_unique.json) | `member` | **`hash`** (민감 필드 해시 저장) + **단일 `unique`** + **복합 `unique`** |
|
|
25
|
+
| [04_fk_and_composite_unique.json](04_fk_and_composite_unique.json) | `team_member` | **`fk`** 외래키 참조 + **복합 유니크** (`team_seq + user_seq`) |
|
|
26
|
+
|
|
27
|
+
### 성능 & 운영
|
|
28
|
+
|
|
29
|
+
| 파일 | 엔티티 | 설명 |
|
|
30
|
+
| ------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------- |
|
|
31
|
+
| [05_cache.json](05_cache.json) | `config_item` | **`cache`** 엔티티 레벨 캐시 (`ttl_seconds: 600`) |
|
|
32
|
+
| [06_history_and_hard_delete.json](06_history_and_hard_delete.json) | `article` | **`history_ttl`** 수정 이력 3년 보관 + **`hard_delete: false`** 논리 삭제 |
|
|
33
|
+
| [14_optimistic_lock.json](14_optimistic_lock.json) | `inventory` | **`optimistic_lock`** 동시 수정 충돌 방지 (재고 등 경쟁 조건 있는 데이터) |
|
|
34
|
+
|
|
35
|
+
### 데이터 격리 & 공유
|
|
36
|
+
|
|
37
|
+
| 파일 | 엔티티 | 설명 |
|
|
38
|
+
| ---------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| [07_license_scope.json](07_license_scope.json) | `exchange_rate` | **`license_scope: false`** — 전 라이선스 **공용** 기준 데이터 (환율). `license_seq` 컬럼 없이 모든 테넌트가 공유 |
|
|
40
|
+
| [16_isolated_license.json](16_isolated_license.json) | `organization` | **`isolated: "license"`** — 테넌트 경계를 정의하는 루트 엔티티. `license_seq` FK 없이 직접 소유 (멀티테넌트 루트) |
|
|
41
|
+
|
|
42
|
+
### 특수 모드
|
|
43
|
+
|
|
44
|
+
| 파일 | 엔티티 | 설명 |
|
|
45
|
+
| ------------------------------------------------ | -------------- | -------------------------------------------------------------------------------- |
|
|
46
|
+
| [13_read_only.json](13_read_only.json) | `activity_log` | **`read_only: true`** API로 수정 불가, 서버 내부(훅/SP)에서만 기록되는 감사 로그 |
|
|
47
|
+
| [15_reset_defaults.json](15_reset_defaults.json) | `country` | **`reset_defaults`** 대량 시딩 — `reset-all` 시 국가 코드 자동 입력 |
|
|
48
|
+
|
|
49
|
+
### 훅 (Hooks)
|
|
50
|
+
|
|
51
|
+
| 파일 | 엔티티 | 훅 타입 | 설명 |
|
|
52
|
+
| -------------------------------------------------------- | ------------ | ------------------- | ------------------------------------------------------------------------------- |
|
|
53
|
+
| [08_hook_sql.json](08_hook_sql.json) | `user_point` | `sql` | 실행형 SQL (포인트 이력 INSERT) + 조회형 SQL (`assign_to`로 결과 주입) |
|
|
54
|
+
| [09_hook_entity.json](09_hook_entity.json) | `post` | `entity` | `after_get`/`after_list`에서 관련 엔티티 자동 주입 (작성자 프로필, 댓글) |
|
|
55
|
+
| [10_hook_submit_delete.json](10_hook_submit_delete.json) | `project` | `submit` / `delete` | 생성 시 연관 엔티티 자동 생성(Upsert 포함), 삭제 시 연관 데이터 자동 정리 |
|
|
56
|
+
| [11_hook_webhook.json](11_hook_webhook.json) | `payment` | `webhook` | 외부 HTTP 통보 — 비동기(결제 생성) + 동기(상태 변경) |
|
|
57
|
+
| [12_hook_push.json](12_hook_push.json) | `delivery` | `push` | 배송 상태 변경 시 고객 푸시 알림 (FCM / APNs 공통, 등록된 모든 디바이스로 전송) |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 훅 타입 한눈에 보기
|
|
62
|
+
|
|
63
|
+
| 훅 타입 | 핵심 필드 | 주요 용도 | 샘플 |
|
|
64
|
+
| --------- | ------------------------------------------ | ------------------------- | ---------------------------- |
|
|
65
|
+
| `sql` | `query`, `params` | SQL 실행 또는 결과 주입 | `08_hook_sql.json` |
|
|
66
|
+
| `entity` | `entity`, `action`, `assign_to` | 관련 데이터 자동 로드 | `09_hook_entity.json` |
|
|
67
|
+
| `submit` | `entity`, `data`, `match` | 다른 엔티티 생성/수정 | `10_hook_submit_delete.json` |
|
|
68
|
+
| `delete` | `entity`, `match` | 다른 엔티티 삭제 | `10_hook_submit_delete.json` |
|
|
69
|
+
| `webhook` | `url`, `body`, `async` | 외부 HTTP 호출 | `11_hook_webhook.json` |
|
|
70
|
+
| `push` | `target_account_seq`, `title`, `push_body` | FCM / APNs 푸시 알림 전송 | `12_hook_push.json` |
|
|
71
|
+
|
|
72
|
+
## 훅 실행 시점
|
|
73
|
+
|
|
74
|
+
| 시점 | 설명 | 주로 사용 |
|
|
75
|
+
| --------------- | --------------------- | -------------------------- |
|
|
76
|
+
| `before_insert` | 데이터 삽입 전 | 유효성 검사, 값 주입 |
|
|
77
|
+
| `after_insert` | 데이터 삽입 후 | 감사 로그, 알림, 연관 생성 |
|
|
78
|
+
| `before_update` | 데이터 수정 전 | 변경 검증, 권한 확인 |
|
|
79
|
+
| `after_update` | 데이터 수정 후 | 변경 이력, 상태 알림 |
|
|
80
|
+
| `before_delete` | 데이터 삭제 전 | 참조 무결성 확인 |
|
|
81
|
+
| `after_delete` | 데이터 삭제 후 | 연관 데이터 정리 |
|
|
82
|
+
| `after_get` | seq로 단건 조회 후 | 관련 데이터 병합 |
|
|
83
|
+
| `after_find` | 조건으로 단건 조회 후 | 관련 데이터 병합 |
|
|
84
|
+
| `after_list` | 목록 조회 후 | 각 항목 추가 정보 주입 |
|
|
85
|
+
|
|
86
|
+
## 훅 템플릿 변수
|
|
87
|
+
|
|
88
|
+
| 변수 | 설명 |
|
|
89
|
+
| -------------- | ----------------------------- |
|
|
90
|
+
| `${new.field}` | 삽입/수정 후 데이터의 필드 값 |
|
|
91
|
+
| `${old.field}` | 수정/삭제 전 데이터의 필드 값 |
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
/// baseUrl: 'http://your-server:47200',
|
|
13
13
|
/// apiKey: 'your-api-key',
|
|
14
14
|
/// hmacSecret: 'your-hmac-secret',
|
|
15
|
-
/// magicLen: 4, // 서버 packet_magic_len 과 동일
|
|
16
15
|
/// );
|
|
17
16
|
/// final result = await client.list('product');
|
|
18
17
|
/// ```
|
|
@@ -31,6 +30,7 @@
|
|
|
31
30
|
/// ```
|
|
32
31
|
|
|
33
32
|
import 'dart:convert';
|
|
33
|
+
import 'dart:math';
|
|
34
34
|
import 'dart:typed_data';
|
|
35
35
|
|
|
36
36
|
import 'package:cryptography/cryptography.dart';
|
|
@@ -41,37 +41,94 @@ class EntityServerClient {
|
|
|
41
41
|
final String baseUrl;
|
|
42
42
|
final String apiKey;
|
|
43
43
|
final String hmacSecret;
|
|
44
|
+
String token;
|
|
44
45
|
final int magicLen;
|
|
46
|
+
/// true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다.
|
|
47
|
+
final bool encryptRequests;
|
|
45
48
|
|
|
46
49
|
final _uuid = const Uuid();
|
|
47
50
|
String? _activeTxId;
|
|
51
|
+
bool _packetEncryption = false;
|
|
48
52
|
|
|
49
53
|
EntityServerClient({
|
|
50
54
|
this.baseUrl = 'http://localhost:47200',
|
|
51
55
|
this.apiKey = '',
|
|
52
56
|
this.hmacSecret = '',
|
|
53
|
-
this.
|
|
57
|
+
this.token = '',
|
|
58
|
+
this.encryptRequests = false,
|
|
54
59
|
});
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
/// JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다.
|
|
62
|
+
void setToken(String newToken) => token = newToken;
|
|
63
|
+
|
|
64
|
+
// ─── Health Check ──────────────────────────────────────────────
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
/// 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
67
|
+
/// 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
68
|
+
Future<Map<String, dynamic>> checkHealth() async {
|
|
69
|
+
final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/$'), '')}/v1/health');
|
|
70
|
+
final res = await http.get(uri);
|
|
71
|
+
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
|
72
|
+
if (data['packet_encryption'] == true) {
|
|
73
|
+
_packetEncryption = true;
|
|
74
|
+
}
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
60
77
|
|
|
61
|
-
|
|
62
|
-
_request('GET', '/v1/entity/$entity/list?page=$page&limit=$limit');
|
|
78
|
+
// ─── CRUD ─────────────────────────────────────────────────────────
|
|
63
79
|
|
|
64
|
-
Future<Map<String, dynamic>>
|
|
65
|
-
_request('GET', '/v1/entity/$entity
|
|
80
|
+
Future<Map<String, dynamic>> get(String entity, int seq, {bool skipHooks = false}) =>
|
|
81
|
+
_request('GET', '/v1/entity/$entity/$seq${skipHooks ? "?skipHooks=true" : ""}');
|
|
66
82
|
|
|
67
|
-
|
|
83
|
+
/// 조건으로 단건 조회 (POST + conditions body)
|
|
84
|
+
///
|
|
85
|
+
/// [conditions] 는 index/hash/unique 필드에만 사용 가능합니다.
|
|
86
|
+
/// 조건에 맞는 행이 없으면 예외가 발생합니다 (404).
|
|
87
|
+
Future<Map<String, dynamic>> find(
|
|
68
88
|
String entity,
|
|
69
|
-
|
|
89
|
+
Map<String, dynamic> conditions, {
|
|
90
|
+
bool skipHooks = false,
|
|
91
|
+
}) =>
|
|
92
|
+
_request('POST', '/v1/entity/$entity/find${skipHooks ? "?skipHooks=true" : ""}',
|
|
93
|
+
body: conditions);
|
|
94
|
+
|
|
95
|
+
/// 목록 조회 (POST + conditions body)
|
|
96
|
+
///
|
|
97
|
+
/// [fields] 를 미지정하면 기본적으로 인덱스 필드만 반환합니다 (가장 빠름).
|
|
98
|
+
/// 전체 필드 반환이 필요하면 `['*']` 를 지정하세요.
|
|
99
|
+
/// [conditions] 는 index/hash/unique 필드에만 사용 가능합니다.
|
|
100
|
+
Future<Map<String, dynamic>> list(
|
|
101
|
+
String entity, {
|
|
70
102
|
int page = 1,
|
|
71
103
|
int limit = 20,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
String? orderBy,
|
|
105
|
+
List<String>? fields,
|
|
106
|
+
Map<String, dynamic>? conditions,
|
|
107
|
+
}) {
|
|
108
|
+
final qParts = <String>['page=$page', 'limit=$limit'];
|
|
109
|
+
if (orderBy != null) qParts.add('order_by=$orderBy');
|
|
110
|
+
if (fields != null && fields.isNotEmpty) qParts.add('fields=${fields.join(",")}');
|
|
111
|
+
return _request('POST', '/v1/entity/$entity/list?${qParts.join("&")}',
|
|
112
|
+
body: conditions ?? {});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// 건수 조회
|
|
116
|
+
Future<Map<String, dynamic>> count(String entity, {Map<String, dynamic>? conditions}) =>
|
|
117
|
+
_request('POST', '/v1/entity/$entity/count', body: conditions ?? {});
|
|
118
|
+
|
|
119
|
+
/// 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
120
|
+
///
|
|
121
|
+
/// [sql] 은 SELECT 쿼리만 허용합니다. 사용자 입력은 반드시 [params] 로 바인딩하세요.
|
|
122
|
+
Future<Map<String, dynamic>> query(
|
|
123
|
+
String entity,
|
|
124
|
+
String sql, {
|
|
125
|
+
List<dynamic>? params,
|
|
126
|
+
int? limit,
|
|
127
|
+
}) {
|
|
128
|
+
final body = <String, dynamic>{'sql': sql, 'params': params ?? []};
|
|
129
|
+
if (limit != null) body['limit'] = limit;
|
|
130
|
+
return _request('POST', '/v1/entity/$entity/query', body: body);
|
|
131
|
+
}
|
|
75
132
|
|
|
76
133
|
/// 트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
|
|
77
134
|
/// 이후 submit / delete 가 서버 큐에 쌓이고 transCommit() 시 일괄 처리됩니다.
|
|
@@ -103,18 +160,24 @@ class EntityServerClient {
|
|
|
103
160
|
String entity,
|
|
104
161
|
Map<String, dynamic> data, {
|
|
105
162
|
String? transactionId,
|
|
163
|
+
bool skipHooks = false,
|
|
106
164
|
}) {
|
|
107
165
|
final txId = transactionId ?? _activeTxId;
|
|
108
|
-
|
|
166
|
+
final q = skipHooks ? '?skipHooks=true' : '';
|
|
167
|
+
return _request('POST', '/v1/entity/$entity/submit$q',
|
|
109
168
|
body: data,
|
|
110
169
|
extraHeaders: txId != null ? {'X-Transaction-ID': txId} : null);
|
|
111
170
|
}
|
|
112
171
|
|
|
172
|
+
/// 삭제. 서버는 POST /delete/:seq 로만 처리합니다.
|
|
113
173
|
Future<Map<String, dynamic>> delete(String entity, int seq,
|
|
114
|
-
{String? transactionId, bool hard = false}) {
|
|
115
|
-
final
|
|
174
|
+
{String? transactionId, bool hard = false, bool skipHooks = false}) {
|
|
175
|
+
final qParts = <String>[];
|
|
176
|
+
if (hard) qParts.add('hard=true');
|
|
177
|
+
if (skipHooks) qParts.add('skipHooks=true');
|
|
178
|
+
final q = qParts.isNotEmpty ? '?${qParts.join("&")}' : '';
|
|
116
179
|
final txId = transactionId ?? _activeTxId;
|
|
117
|
-
return _request('
|
|
180
|
+
return _request('POST', '/v1/entity/$entity/delete/$seq$q',
|
|
118
181
|
extraHeaders: txId != null ? {'X-Transaction-ID': txId} : null);
|
|
119
182
|
}
|
|
120
183
|
|
|
@@ -125,6 +188,97 @@ class EntityServerClient {
|
|
|
125
188
|
Future<Map<String, dynamic>> rollback(String entity, int historySeq) =>
|
|
126
189
|
_request('POST', '/v1/entity/$entity/rollback/$historySeq');
|
|
127
190
|
|
|
191
|
+
/// 푸시 발송 트리거 엔티티에 submit합니다.
|
|
192
|
+
Future<Map<String, dynamic>> push(
|
|
193
|
+
String pushEntity,
|
|
194
|
+
Map<String, dynamic> payload, {
|
|
195
|
+
String? transactionId,
|
|
196
|
+
}) =>
|
|
197
|
+
submit(pushEntity, payload, transactionId: transactionId);
|
|
198
|
+
|
|
199
|
+
/// push_log 목록 조회 헬퍼
|
|
200
|
+
Future<Map<String, dynamic>> pushLogList({int page = 1, int limit = 20}) =>
|
|
201
|
+
list('push_log', page: page, limit: limit);
|
|
202
|
+
|
|
203
|
+
/// account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드)
|
|
204
|
+
Future<Map<String, dynamic>> registerPushDevice(
|
|
205
|
+
int accountSeq,
|
|
206
|
+
String deviceId,
|
|
207
|
+
String pushToken, {
|
|
208
|
+
String? platform,
|
|
209
|
+
String? deviceType,
|
|
210
|
+
String? browser,
|
|
211
|
+
String? browserVersion,
|
|
212
|
+
bool pushEnabled = true,
|
|
213
|
+
String? transactionId,
|
|
214
|
+
}) {
|
|
215
|
+
final payload = <String, dynamic>{
|
|
216
|
+
'id': deviceId,
|
|
217
|
+
'account_seq': accountSeq,
|
|
218
|
+
'push_token': pushToken,
|
|
219
|
+
'push_enabled': pushEnabled,
|
|
220
|
+
if (platform != null && platform.isNotEmpty) 'platform': platform,
|
|
221
|
+
if (deviceType != null && deviceType.isNotEmpty)
|
|
222
|
+
'device_type': deviceType,
|
|
223
|
+
if (browser != null && browser.isNotEmpty) 'browser': browser,
|
|
224
|
+
if (browserVersion != null && browserVersion.isNotEmpty)
|
|
225
|
+
'browser_version': browserVersion,
|
|
226
|
+
};
|
|
227
|
+
return submit('account_device', payload, transactionId: transactionId);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// account_device.seq 기준 push_token 갱신 헬퍼
|
|
231
|
+
Future<Map<String, dynamic>> updatePushDeviceToken(
|
|
232
|
+
int deviceSeq,
|
|
233
|
+
String pushToken, {
|
|
234
|
+
bool pushEnabled = true,
|
|
235
|
+
String? transactionId,
|
|
236
|
+
}) {
|
|
237
|
+
return submit('account_device', {
|
|
238
|
+
'seq': deviceSeq,
|
|
239
|
+
'push_token': pushToken,
|
|
240
|
+
'push_enabled': pushEnabled,
|
|
241
|
+
}, transactionId: transactionId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// account_device.seq 기준 푸시 수신 비활성화 헬퍼
|
|
245
|
+
Future<Map<String, dynamic>> disablePushDevice(
|
|
246
|
+
int deviceSeq, {
|
|
247
|
+
String? transactionId,
|
|
248
|
+
}) {
|
|
249
|
+
return submit('account_device', {
|
|
250
|
+
'seq': deviceSeq,
|
|
251
|
+
'push_enabled': false,
|
|
252
|
+
}, transactionId: transactionId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// 요청 본문을 읽어 JSON으로 반환합니다.
|
|
256
|
+
/// - application/octet-stream: 암호 패킷 복호화
|
|
257
|
+
/// - 그 외: 평문 JSON 파싱
|
|
258
|
+
Future<Map<String, dynamic>> readRequestBody(
|
|
259
|
+
Uint8List rawBody, {
|
|
260
|
+
String contentType = 'application/json',
|
|
261
|
+
bool requireEncrypted = false,
|
|
262
|
+
}) async {
|
|
263
|
+
final lowered = contentType.toLowerCase();
|
|
264
|
+
final isEncrypted = lowered.contains('application/octet-stream');
|
|
265
|
+
|
|
266
|
+
if (requireEncrypted && !isEncrypted) {
|
|
267
|
+
throw Exception(
|
|
268
|
+
'Encrypted request required: Content-Type must be application/octet-stream');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (isEncrypted) {
|
|
272
|
+
if (rawBody.isEmpty) {
|
|
273
|
+
throw Exception('Encrypted request body is empty');
|
|
274
|
+
}
|
|
275
|
+
return await _decryptPacket(rawBody);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (rawBody.isEmpty) return {};
|
|
279
|
+
return jsonDecode(utf8.decode(rawBody)) as Map<String, dynamic>;
|
|
280
|
+
}
|
|
281
|
+
|
|
128
282
|
// ─── 내부 ─────────────────────────────────────────────────────────
|
|
129
283
|
|
|
130
284
|
Future<Map<String, dynamic>> _request(
|
|
@@ -133,21 +287,37 @@ class EntityServerClient {
|
|
|
133
287
|
Object? body,
|
|
134
288
|
Map<String, String>? extraHeaders,
|
|
135
289
|
}) async {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
290
|
+
// 요청 바디 결정: encryptRequests 시 POST 바디를 암호화
|
|
291
|
+
Uint8List? bodyBytes;
|
|
292
|
+
String contentType = 'application/json';
|
|
293
|
+
if (body != null) {
|
|
294
|
+
final jsonBytes = Uint8List.fromList(utf8.encode(jsonEncode(body)));
|
|
295
|
+
if (encryptRequests || _packetEncryption) {
|
|
296
|
+
bodyBytes = await _encryptPacket(jsonBytes);
|
|
297
|
+
contentType = 'application/octet-stream';
|
|
298
|
+
} else {
|
|
299
|
+
bodyBytes = jsonBytes;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
final isHmacMode = apiKey.isNotEmpty && hmacSecret.isNotEmpty;
|
|
141
304
|
|
|
142
305
|
final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/$'), '')}$path');
|
|
143
|
-
final headers = <String, String>{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
306
|
+
final headers = <String, String>{'Content-Type': contentType};
|
|
307
|
+
|
|
308
|
+
if (isHmacMode) {
|
|
309
|
+
final timestamp =
|
|
310
|
+
(DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
|
|
311
|
+
final nonce = _uuid.v4();
|
|
312
|
+
final signature = await _sign(method, path, timestamp, nonce, bodyBytes ?? Uint8List(0));
|
|
313
|
+
headers['X-API-Key'] = apiKey;
|
|
314
|
+
headers['X-Timestamp'] = timestamp;
|
|
315
|
+
headers['X-Nonce'] = nonce;
|
|
316
|
+
headers['X-Signature'] = signature;
|
|
317
|
+
} else if (token.isNotEmpty) {
|
|
318
|
+
headers['Authorization'] = 'Bearer $token';
|
|
319
|
+
}
|
|
320
|
+
if (extraHeaders != null) headers.addAll(extraHeaders);
|
|
151
321
|
|
|
152
322
|
final http.Response res;
|
|
153
323
|
switch (method.toUpperCase()) {
|
|
@@ -156,38 +326,78 @@ class EntityServerClient {
|
|
|
156
326
|
break;
|
|
157
327
|
case 'DELETE':
|
|
158
328
|
res = await http.delete(uri, headers: headers,
|
|
159
|
-
body:
|
|
329
|
+
body: bodyBytes != null && bodyBytes.isNotEmpty ? bodyBytes : null);
|
|
160
330
|
break;
|
|
161
331
|
default:
|
|
162
332
|
res = await http.post(uri, headers: headers,
|
|
163
|
-
body:
|
|
333
|
+
body: bodyBytes != null && bodyBytes.isNotEmpty ? bodyBytes : null);
|
|
164
334
|
}
|
|
165
335
|
|
|
166
|
-
final
|
|
336
|
+
final ct = res.headers['content-type'] ?? '';
|
|
167
337
|
|
|
168
338
|
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
169
|
-
if (
|
|
339
|
+
if (ct.contains('application/octet-stream')) {
|
|
170
340
|
return await _decryptPacket(res.bodyBytes);
|
|
171
341
|
}
|
|
172
342
|
|
|
173
343
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
|
174
344
|
if (data['ok'] != true) {
|
|
175
|
-
throw Exception('EntityServer error:
|
|
345
|
+
throw Exception('EntityServer error: \${data['message']} (HTTP \${res.statusCode})');
|
|
176
346
|
}
|
|
177
347
|
return data;
|
|
178
348
|
}
|
|
179
349
|
|
|
350
|
+
/// 패킷 암호화 키를 유도합니다.
|
|
351
|
+
/// - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
352
|
+
/// - JWT 모드: SHA256(token)
|
|
353
|
+
Future<Uint8List> _derivePacketKey() async {
|
|
354
|
+
if (token.isNotEmpty && hmacSecret.isEmpty) {
|
|
355
|
+
final sha = Sha256();
|
|
356
|
+
final hash = await sha.hash(utf8.encode(token));
|
|
357
|
+
return Uint8List.fromList(hash.bytes);
|
|
358
|
+
}
|
|
359
|
+
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
|
360
|
+
final output = await hkdf.deriveKey(
|
|
361
|
+
secretKey: SecretKey(utf8.encode(hmacSecret)),
|
|
362
|
+
nonce: utf8.encode('entity-server:hkdf:v1'),
|
|
363
|
+
info: utf8.encode('entity-server:packet-encryption'),
|
|
364
|
+
);
|
|
365
|
+
return Uint8List.fromList(await output.extractBytes());
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
static Uint8List _randomBytes(int count) {
|
|
369
|
+
final rng = Random.secure();
|
|
370
|
+
return Uint8List.fromList(List.generate(count, (_) => rng.nextInt(256)));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// XChaCha20-Poly1305 패킷 암호화
|
|
374
|
+
/// 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
375
|
+
/// magicLen: 2 + keyBytes[31] % 14
|
|
376
|
+
Future<Uint8List> _encryptPacket(Uint8List plaintext) async {
|
|
377
|
+
final keyBytes = await _derivePacketKey();
|
|
378
|
+
final key = SecretKey(keyBytes);
|
|
379
|
+
final magicLen = 2 + keyBytes[31] % 14;
|
|
380
|
+
final magic = _randomBytes(magicLen);
|
|
381
|
+
final nonce = _randomBytes(24);
|
|
382
|
+
|
|
383
|
+
final algorithm = Xchacha20.poly1305Aead();
|
|
384
|
+
final secretBox = await algorithm.encrypt(plaintext, secretKey: key, nonce: nonce);
|
|
385
|
+
// 포맷: ciphertext + mac(16)
|
|
386
|
+
final cipherBytes = Uint8List.fromList([
|
|
387
|
+
...secretBox.cipherText,
|
|
388
|
+
...secretBox.mac.bytes,
|
|
389
|
+
]);
|
|
390
|
+
return Uint8List.fromList([...magic, ...nonce, ...cipherBytes]);
|
|
391
|
+
}
|
|
392
|
+
|
|
180
393
|
/// XChaCha20-Poly1305 패킷 복호화
|
|
181
394
|
/// 포맷: [magic:magicLen][nonce:24][ciphertext+tag:...]
|
|
182
|
-
/// 키:
|
|
395
|
+
/// 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
183
396
|
Future<Map<String, dynamic>> _decryptPacket(Uint8List data) async {
|
|
184
|
-
|
|
185
|
-
final
|
|
186
|
-
final
|
|
187
|
-
final key = SecretKey(keyHash.bytes);
|
|
188
|
-
|
|
397
|
+
final keyBytes = await _derivePacketKey();
|
|
398
|
+
final key = SecretKey(keyBytes);
|
|
399
|
+
final magicLen = 2 + keyBytes[31] % 14;
|
|
189
400
|
final nonce = data.sublist(magicLen, magicLen + 24);
|
|
190
|
-
// ciphertext 마지막 16바이트가 Poly1305 MAC
|
|
191
401
|
final ciphertextWithMac = data.sublist(magicLen + 24);
|
|
192
402
|
|
|
193
403
|
final algorithm = Xchacha20.poly1305Aead();
|
|
@@ -201,18 +411,21 @@ class EntityServerClient {
|
|
|
201
411
|
return jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
|
|
202
412
|
}
|
|
203
413
|
|
|
204
|
-
/// HMAC-SHA256
|
|
414
|
+
/// HMAC-SHA256 서명. bodyBytes 는 JSON 또는 암호화된 바이너리 모두 지원합니다.
|
|
205
415
|
Future<String> _sign(
|
|
206
416
|
String method,
|
|
207
417
|
String path,
|
|
208
418
|
String timestamp,
|
|
209
419
|
String nonce,
|
|
210
|
-
|
|
420
|
+
Uint8List bodyBytes,
|
|
211
421
|
) async {
|
|
212
|
-
final payload = [method, path, timestamp, nonce, body].join('|');
|
|
213
422
|
final algorithm = Hmac.sha256();
|
|
214
|
-
final
|
|
215
|
-
final
|
|
423
|
+
final secretKey = SecretKey(utf8.encode(hmacSecret));
|
|
424
|
+
final prefix = utf8.encode('\$method|\$path|\$timestamp|\$nonce|');
|
|
425
|
+
final mac = await algorithm.calculateMac(
|
|
426
|
+
Uint8List.fromList([...prefix, ...bodyBytes]),
|
|
427
|
+
secretKey: secretKey,
|
|
428
|
+
);
|
|
216
429
|
return mac.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
217
430
|
}
|
|
218
431
|
}
|