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.
Files changed (56) hide show
  1. package/bin/create.js +26 -8
  2. package/package.json +1 -1
  3. package/template/.env.example +20 -3
  4. package/template/configs/database.json +173 -10
  5. package/template/configs/jwt.json +1 -0
  6. package/template/configs/oauth.json +37 -0
  7. package/template/configs/push.json +26 -0
  8. package/template/entities/Account/account_audit.json +4 -5
  9. package/template/entities/README.md +4 -4
  10. package/template/entities/{Auth → System/Auth}/account.json +0 -14
  11. package/template/entities/System/system_audit_log.json +14 -8
  12. package/template/samples/README.md +43 -21
  13. package/template/samples/browser/entity-server-client.js +453 -0
  14. package/template/samples/browser/example.html +498 -0
  15. package/template/samples/entities/01_basic_fields.json +39 -0
  16. package/template/samples/entities/02_types_and_defaults.json +67 -0
  17. package/template/samples/entities/03_hash_and_unique.json +33 -0
  18. package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
  19. package/template/samples/entities/05_cache.json +55 -0
  20. package/template/samples/entities/06_history_and_hard_delete.json +60 -0
  21. package/template/samples/entities/07_license_scope.json +52 -0
  22. package/template/samples/entities/08_hook_sql.json +52 -0
  23. package/template/samples/entities/09_hook_entity.json +65 -0
  24. package/template/samples/entities/10_hook_submit_delete.json +78 -0
  25. package/template/samples/entities/11_hook_webhook.json +84 -0
  26. package/template/samples/entities/12_hook_push.json +73 -0
  27. package/template/samples/entities/13_read_only.json +54 -0
  28. package/template/samples/entities/14_optimistic_lock.json +29 -0
  29. package/template/samples/entities/15_reset_defaults.json +94 -0
  30. package/template/samples/entities/16_isolated_license.json +62 -0
  31. package/template/samples/entities/README.md +91 -0
  32. package/template/samples/flutter/lib/entity_server_client.dart +261 -48
  33. package/template/samples/java/EntityServerClient.java +325 -61
  34. package/template/samples/java/EntityServerExample.java +4 -3
  35. package/template/samples/kotlin/EntityServerClient.kt +261 -45
  36. package/template/samples/node/src/EntityServerClient.js +348 -59
  37. package/template/samples/node/src/example.js +9 -9
  38. package/template/samples/php/ci4/Config/EntityServer.php +14 -0
  39. package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
  40. package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
  41. package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
  42. package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
  43. package/template/samples/python/entity_server.py +287 -68
  44. package/template/samples/python/example.py +7 -6
  45. package/template/samples/react/src/example.tsx +41 -25
  46. package/template/samples/swift/EntityServerClient.swift +248 -37
  47. package/template/scripts/normalize-entities.sh +10 -10
  48. package/template/scripts/run.ps1 +12 -3
  49. package/template/scripts/run.sh +120 -37
  50. package/template/scripts/update-server.ps1 +160 -4
  51. package/template/scripts/update-server.sh +132 -4
  52. package/template/samples/react/src/api/entityServerClient.ts +0 -290
  53. package/template/samples/react/src/hooks/useEntity.ts +0 -105
  54. /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
  55. /package/template/entities/{Auth → System/Auth}/license.json +0 -0
  56. /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.magicLen = 4,
57
+ this.token = '',
58
+ this.encryptRequests = false,
54
59
  });
55
60
 
56
- // ─── CRUD ─────────────────────────────────────────────────────────
61
+ /// JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다.
62
+ void setToken(String newToken) => token = newToken;
63
+
64
+ // ─── Health Check ──────────────────────────────────────────────
57
65
 
58
- Future<Map<String, dynamic>> get(String entity, int seq) =>
59
- _request('GET', '/v1/entity/$entity/$seq');
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
- Future<Map<String, dynamic>> list(String entity, {int page = 1, int limit = 20}) =>
62
- _request('GET', '/v1/entity/$entity/list?page=$page&limit=$limit');
78
+ // ─── CRUD ─────────────────────────────────────────────────────────
63
79
 
64
- Future<Map<String, dynamic>> count(String entity) =>
65
- _request('GET', '/v1/entity/$entity/count');
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
- Future<Map<String, dynamic>> query(
83
+ /// 조건으로 단건 조회 (POST + conditions body)
84
+ ///
85
+ /// [conditions] 는 index/hash/unique 필드에만 사용 가능합니다.
86
+ /// 조건에 맞는 행이 없으면 예외가 발생합니다 (404).
87
+ Future<Map<String, dynamic>> find(
68
88
  String entity,
69
- List<Map<String, dynamic>> filter, {
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
- _request('POST', '/v1/entity/$entity/query?page=$page&limit=$limit',
74
- body: filter);
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
- return _request('POST', '/v1/entity/$entity/submit',
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 q = hard ? '?hard=true' : '';
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('DELETE', '/v1/entity/$entity/delete/$seq$q',
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
- final bodyStr = body != null ? jsonEncode(body) : '';
137
- final timestamp =
138
- (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
139
- final nonce = _uuid.v4();
140
- final signature = await _sign(method, path, timestamp, nonce, bodyStr);
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
- 'Content-Type': 'application/json',
145
- 'X-API-Key': apiKey,
146
- 'X-Timestamp': timestamp,
147
- 'X-Nonce': nonce,
148
- 'X-Signature': signature,
149
- if (extraHeaders != null) ...extraHeaders,
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: bodyStr.isNotEmpty ? bodyStr : null);
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: bodyStr.isNotEmpty ? bodyStr : null);
333
+ body: bodyBytes != null && bodyBytes.isNotEmpty ? bodyBytes : null);
164
334
  }
165
335
 
166
- final contentType = res.headers['content-type'] ?? '';
336
+ final ct = res.headers['content-type'] ?? '';
167
337
 
168
338
  // 패킷 암호화 응답: application/octet-stream → 복호화
169
- if (contentType.contains('application/octet-stream')) {
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: ${data['message']} (HTTP ${res.statusCode})');
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
- /// 키: sha256(hmac_secret)
395
+ /// 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
183
396
  Future<Map<String, dynamic>> _decryptPacket(Uint8List data) async {
184
- // 유도: sha256(hmac_secret)
185
- final sha256 = Sha256();
186
- final keyHash = await sha256.hash(utf8.encode(hmacSecret));
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
- String body,
420
+ Uint8List bodyBytes,
211
421
  ) async {
212
- final payload = [method, path, timestamp, nonce, body].join('|');
213
422
  final algorithm = Hmac.sha256();
214
- final key = SecretKey(utf8.encode(hmacSecret));
215
- final mac = await algorithm.calculateMac(utf8.encode(payload), secretKey: key);
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
  }