@vibexnpm/talkx 5.1.0 → 5.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibexnpm/talkx",
3
- "version": "5.1.0",
3
+ "version": "5.3.0",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -320,6 +320,21 @@ class ChatClient extends EventEmitter {
320
320
  // 1) WebSocket 구독을 먼저 — 이후 서버 read 이벤트 유실 방지
321
321
  if (!wasAlreadySubscribed) {
322
322
  await this.subscribeRoom(roomId);
323
+ } else if (this.assistantStreamReplay) {
324
+ // ⭐ <b>이미 구독 중인 방으로 재입장</b> — 구독을 유지한 채 방 화면만 나갔다 들어오는 흐름이다
325
+ // (목록 화면에서 unread 를 받으려면 구독을 끊지 않는 것이 정상 구현이다). 이때 subscribeRoom 이
326
+ // 통째로 건너뛰어져 <b>스냅샷 트리거가 하나도 돌지 않는다</b> — 재개 경로는 여기와 재연결
327
+ // (`resumeAssistantStreams`) 둘뿐이기 때문이다.
328
+ //
329
+ // 진행 표시는 transient 이벤트라 화면이 언마운트되면 프론트에서 사라지는데, SDK 슬롯에만 남은
330
+ // 그 상태를 다시 그려줄 신호가 없다 → 다음 PHASE 가 올 때까지(워커 heartbeat 간격 15초) 배지가
331
+ // 안 뜬다. 스냅샷을 다시 받아 그 공백을 없앤다.
332
+ //
333
+ // await 하지 않는다 — 구독은 살아 있으므로 그 사이 도착분은 보류함에 쌓였다가 병합 후 순서대로
334
+ // 풀린다(§6.1). 실패해도 보류분은 반드시 풀리므로 라이브만으로 계속 동작한다.
335
+ const generation = this._armAssistantSnapshot(roomId);
336
+ this._loadAssistantSnapshot(roomId, generation)
337
+ .catch((error) => this.logger.warn(`Assistant snapshot on re-enter failed: room=${roomId}`, error));
323
338
  }
324
339
  // 2) 자동 읽음 처리 활성 방 지정
325
340
  this.setActiveRoom(roomId);
@@ -1017,6 +1032,222 @@ class ChatClient extends EventEmitter {
1017
1032
  return this.apiClient.post(`/api/v1/rooms/group/${roomId}/unpin`);
1018
1033
  }
1019
1034
 
1035
+ // ── 투표 ────────────────────────────────────────────────────────────────
1036
+ //
1037
+ // 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 모든 호출은 채팅 서버를 거치고,
1038
+ // 신원(userId/projectId)은 채팅 서버가 인증 결과로 채워 넣는다.
1039
+ //
1040
+ // 🔴 실패는 서버가 투표 서버의 code 를 <b>그대로</b> 내려준다 — {@code error.response.code} 로 분기한다
1041
+ // (VOTE_ALREADY_CLOSED / VOTE_NOT_EDITABLE / VOTE_MODIFY_FORBIDDEN / CONFLICT ...).
1042
+ // 409 는 세 종류이고 대응이 다르다: ALREADY_CLOSED·NOT_EDITABLE 은 재시도해도 같고,
1043
+ // CONFLICT 만 <b>재조회 후</b> 재전송하면 성공한다.
1044
+ //
1045
+ // 🔴 결과 마스킹은 <b>호출자마다</b> 계산된다. 목록에는 집계가 없고, 단건 조회/접수 응답에만 있다.
1046
+ // 같은 투표라도 다른 사용자가 보면 가려지므로 응답을 공유 캐시에 넣으면 안 된다.
1047
+
1048
+ /**
1049
+ * 투표 생성.
1050
+ *
1051
+ * <p>🔴 <b>{@code idempotencyKey} 는 필수다.</b> SDK 가 대신 만들지 않는다 — 만들면 그 값이 호출자에게
1052
+ * 남지 않아, 응답만 유실된 타임아웃에서 <b>복구가 불가능</b>하다(다음 호출이 새 키를 만들어 투표가
1053
+ * 두 개 생긴다). 호출자가 키를 소유하고, 같은 시도의 재시도에는 <b>같은 키</b>를 넘긴다 —
1054
+ * 그러면 서버가 기존 투표를 돌려준다(응답도 201, {@code voteId} 도 같다).</p>
1055
+ *
1056
+ * @param {string} roomId - 채팅방 ID
1057
+ * @param {Object} vote - 투표 폼
1058
+ * @param {string} vote.question - 질문 (최대 500자)
1059
+ * @param {'SINGLE'|'MULTIPLE'} vote.type
1060
+ * @param {number} vote.maxSelections - 양수. {@code SINGLE} 이면 1
1061
+ * @param {'TEXT'|'TEXT_WITH_IMAGE'} vote.optionType - {@code 'IMAGE'} 라는 값은 없다
1062
+ * @param {number} vote.closeAt - 마감 시각 (epoch millis). 과거·현재 불가 · 최대 30일 뒤
1063
+ * @param {Array<{id: string, content: string, order: number, imageUrl?: string}>} vote.options - 2~10개.
1064
+ * {@code imageUrl} 은 {@link #uploadVoteImage} 가 돌려준 값을 <b>가공 없이</b> 넣는다
1065
+ * @param {Object} options
1066
+ * @param {string} options.idempotencyKey - <b>필수.</b> 재시도에는 반드시 같은 값을 넘긴다
1067
+ * @returns {Promise<Object>} 생성된 투표 (집계 포함)
1068
+ * @throws {Error} {@code idempotencyKey} 가 없으면 서버를 호출하지 않고 즉시 실패한다
1069
+ * @example
1070
+ * const key = crypto.randomUUID();
1071
+ * const vote = await chat.createVote(roomId, {
1072
+ * question: '점심 뭐 먹지', type: 'SINGLE', optionType: 'TEXT', maxSelections: 1,
1073
+ * closeAt: Date.now() + 86_400_000,
1074
+ * options: [{ id: 'opt-1', content: '삼겹살', order: 0 }, { id: 'opt-2', content: '국밥', order: 1 }]
1075
+ * }, { idempotencyKey: key });
1076
+ */
1077
+ async createVote(roomId, vote, options = {}) {
1078
+ if (!roomId) {
1079
+ throw new Error('roomId is required');
1080
+ }
1081
+ const { idempotencyKey } = options;
1082
+ if (!idempotencyKey) {
1083
+ // SDK 가 만들어 주면 호출자는 그 값을 볼 수 없다 — 타임아웃으로 응답만 유실됐을 때
1084
+ // 같은 키로 재시도할 방법이 사라지고, 다음 호출이 투표를 하나 더 만든다.
1085
+ throw new Error(
1086
+ 'createVote: options.idempotencyKey is required — '
1087
+ + 'generate it once per vote and reuse the same value on retry '
1088
+ + '(재시도에 같은 키를 넘겨야 투표가 중복 생성되지 않는다).'
1089
+ );
1090
+ }
1091
+ const response = await this.apiClient.post(
1092
+ `/api/v1/rooms/${roomId}/votes`,
1093
+ vote,
1094
+ undefined,
1095
+ { 'X-Idempotency-Key': idempotencyKey }
1096
+ );
1097
+ return this._unwrapSuccessResponse(response);
1098
+ }
1099
+
1100
+ /**
1101
+ * 방의 투표 목록 (최신순).
1102
+ *
1103
+ * <p>목록 항목에는 선택지별 집계도 내 선택도 없다 — 참여자 수({@code responseCount})만 있다.
1104
+ * 결과를 보려면 {@link #getVote} 를 쓴다.</p>
1105
+ *
1106
+ * @param {string} roomId - 채팅방 ID
1107
+ * @param {Object} [options]
1108
+ * @param {string} [options.cursor] - 이전 응답의 {@code nextCursor} 를 <b>그대로</b>. 불투명 문자열이라 파싱하지 않는다
1109
+ * @param {number} [options.limit=20] - 최대 100. 초과분은 서버가 조용히 100 으로 보정한다(에러 아님)
1110
+ * @returns {Promise<{items: Array<Object>, nextCursor: ?string}>} {@code items} 가 비면 끝
1111
+ */
1112
+ async getVotes(roomId, options = {}) {
1113
+ const { cursor, limit } = options;
1114
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes`, { cursor, limit });
1115
+ return this._unwrapSuccessResponse(response);
1116
+ }
1117
+
1118
+ /**
1119
+ * 투표 단건 + 집계.
1120
+ *
1121
+ * <p>{@code revealed: false} 일 때의 {@code count}/{@code ratio} 0 은 「참여자가 없다」가 아니라
1122
+ * <b>서버가 가린 값</b>이다. 결과 공개 조건은 「내가 응답했다」 또는 「마감됐다」. 비공개 상태에서도
1123
+ * {@code myOptionIds} 는 채워진다(재투표 화면용).</p>
1124
+ *
1125
+ * <p>{@code ratio} 의 분모는 응답자 수이지 총 선택 수가 아니다 — 복수 선택에서 <b>합이 100을 넘는 것이
1126
+ * 정상</b>이고 화면이 「복수 응답」임을 표기해야 한다.</p>
1127
+ *
1128
+ * <p>마감 여부는 {@code status} 만으로 정해지지 않는다. 서버 자동 마감이 미구현이라
1129
+ * {@code status: 'OPEN'} 인데 {@code closeAt} 이 지난 투표가 있고 그건 마감된 것으로 동작한다 —
1130
+ * <b>{@code status} 와 {@code closeAt} 을 함께</b> 봐야 한다.</p>
1131
+ *
1132
+ * @param {string} roomId - 채팅방 ID
1133
+ * @param {string} voteId - 투표 ID
1134
+ * @returns {Promise<Object>}
1135
+ */
1136
+ async getVote(roomId, voteId) {
1137
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes/${voteId}`);
1138
+ return this._unwrapSuccessResponse(response);
1139
+ }
1140
+
1141
+ /**
1142
+ * 투표 수정 — <b>부분 수정이 아니다.</b> 등록 폼 전체를 보낸다({@link #createVote} 와 같은 타입).
1143
+ *
1144
+ * <p>{@code maxSelections}↔{@code type}, {@code optionType}↔{@code imageUrl} 이 서로 묶여 있어
1145
+ * 한쪽만 바꾸면 판독 불가능한 투표가 만들어지기 때문이다.</p>
1146
+ *
1147
+ * <p>조건 둘: <b>개설자만</b> · <b>참여자가 0명일 때만</b>. 표가 들어오면
1148
+ * {@code VOTE_NOT_EDITABLE}(409), 개설자가 아니면 {@code VOTE_MODIFY_FORBIDDEN}(403).
1149
+ * 선택지 {@code id} 를 이전 값과 맞출 필요는 없다 — 참여자가 0명이라 어긋날 응답이 없다.</p>
1150
+ *
1151
+ * @param {string} roomId - 채팅방 ID
1152
+ * @param {string} voteId - 투표 ID
1153
+ * @param {Object} vote - 투표 폼 (생성과 동일)
1154
+ * @returns {Promise<Object>}
1155
+ */
1156
+ async updateVote(roomId, voteId, vote) {
1157
+ const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}`, vote);
1158
+ return this._unwrapSuccessResponse(response);
1159
+ }
1160
+
1161
+ /**
1162
+ * 투표 삭제 — 개설자면 된다. 마감 여부도 참여자 유무도 보지 않는다.
1163
+ *
1164
+ * <p>채팅에 이미 게시된 투표 카드는 <b>그대로 남는다</b>(서버가 발행된 메시지를 고칠 수 없다).
1165
+ * 원본 카드를 누르면 404 가 오고, 이 404 를 「취소된 투표」로 표시하는 것은 화면 몫이다.
1166
+ * 삭제 사실은 별도의 취소 카드 메시지로도 방에 발행된다.</p>
1167
+ *
1168
+ * @param {string} roomId - 채팅방 ID
1169
+ * @param {string} voteId - 투표 ID
1170
+ * @returns {Promise<void>} 본문 없음 (204)
1171
+ */
1172
+ async deleteVote(roomId, voteId) {
1173
+ await this.apiClient.delete(`/api/v1/rooms/${roomId}/votes/${voteId}`);
1174
+ }
1175
+
1176
+ /**
1177
+ * 수동 마감 (개설자만).
1178
+ *
1179
+ * <p>본문이 없으므로 마감 후 결과를 보려면 {@link #getVote} 를 다시 호출한다.
1180
+ * 이미 마감이면 {@code VOTE_ALREADY_CLOSED}(409), 개설자가 아니면 {@code VOTE_CLOSE_FORBIDDEN}(403)
1181
+ * — 수정의 403 과 code 가 다르다.</p>
1182
+ *
1183
+ * @param {string} roomId - 채팅방 ID
1184
+ * @param {string} voteId - 투표 ID
1185
+ * @returns {Promise<void>} 본문 없음 (204)
1186
+ */
1187
+ async closeVote(roomId, voteId) {
1188
+ await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}/close`);
1189
+ }
1190
+
1191
+ /**
1192
+ * 투표 접수 · 재투표 — <b>같은 메서드</b>다.
1193
+ *
1194
+ * <p>재투표는 새 자원을 만들지 않고 기존 응답을 덮어쓴다. 클라이언트가 최초/재투표를 구분해 보낼 필요가
1195
+ * 없고, 같은 값을 여러 번 보내도 결과가 같다. 응답에 <b>갱신된 집계가 함께</b> 오므로 별도 폴링이 필요 없다.</p>
1196
+ *
1197
+ * <p>빈 배열은 「투표 취소」로 해석되지 않는다 — 투표를 취소하는 경로는 없다(400).</p>
1198
+ *
1199
+ * <p>여러 규칙을 동시에 위반해도 서버는 <b>먼저 걸린 하나만</b> 응답한다. 하나를 고쳐 재시도하면
1200
+ * 다음 위반이 드러날 수 있어, 모든 오류를 한 번에 수집하는 화면은 성립하지 않는다.</p>
1201
+ *
1202
+ * @param {string} roomId - 채팅방 ID
1203
+ * @param {string} voteId - 투표 ID
1204
+ * @param {string[]} optionIds - 고른 선택지. 빈 배열 불가
1205
+ * @returns {Promise<Object>} 갱신된 투표 (집계 포함)
1206
+ */
1207
+ async submitVote(roomId, voteId, optionIds) {
1208
+ const response = await this.apiClient.put(
1209
+ `/api/v1/rooms/${roomId}/votes/${voteId}/responses`,
1210
+ { optionIds }
1211
+ );
1212
+ return this._unwrapSuccessResponse(response);
1213
+ }
1214
+
1215
+ /**
1216
+ * 선택지 이미지 업로드.
1217
+ *
1218
+ * <p>돌려받은 {@code imageUrl} 을 <b>가공 없이 그대로</b> 생성·수정 본문의 {@code options[].imageUrl} 에
1219
+ * 넣는다 — 서버가 문자열로 비교하고, 이 API 가 발급한 URL 만 통과한다. <b>채팅 첨부 URL 은 쓸 수 없다</b>
1220
+ * (같은 버킷이라도 거절된다).</p>
1221
+ *
1222
+ * <p>형식은 매직넘버로 판정한다(PNG · JPEG · GIF · WEBP) — 신고된 Content-Type 과 확장자는 쓰이지 않으므로
1223
+ * {@code .png} 로 이름 붙인 텍스트 파일은 거절된다. 10MB 를 넘으면 {@code VOTE_IMAGE_TOO_LARGE}.</p>
1224
+ *
1225
+ * <p>{@code optionType: 'TEXT_WITH_IMAGE'} 는 「이미지를 쓸 수 있다」는 상한이지 「모두 이미지다」가 아니다 —
1226
+ * 이미지가 있는 선택지와 없는 선택지를 섞어도 되고 하나도 없어도 된다. 다만 {@code imageUrl} 이
1227
+ * <b>빈 문자열</b>이면 거절된다(없으면 키를 아예 빼거나 {@code null}).</p>
1228
+ *
1229
+ * @param {string} roomId - 채팅방 ID
1230
+ * @param {File|Blob} file - 이미지 파일
1231
+ * @param {Object} [options]
1232
+ * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void}
1233
+ * @param {AbortSignal} [options.signal] - 취소용
1234
+ * @returns {Promise<{imageUrl: string}>}
1235
+ */
1236
+ async uploadVoteImage(roomId, file, options = {}) {
1237
+ if (!roomId) {
1238
+ throw new Error('roomId is required');
1239
+ }
1240
+ if (!file) {
1241
+ throw new Error('file is required');
1242
+ }
1243
+ const response = await this.apiClient.upload(
1244
+ `/api/v1/rooms/${roomId}/votes/images`,
1245
+ file,
1246
+ { onProgress: options.onProgress, signal: options.signal }
1247
+ );
1248
+ return this._unwrapSuccessResponse(response);
1249
+ }
1250
+
1020
1251
  /**
1021
1252
  * 이모지 리액션 토글.
1022
1253
  * <p>한 사용자는 메시지당 하나의 이모지만 보유.
@@ -3274,17 +3505,29 @@ class ChatClient extends EventEmitter {
3274
3505
  * @private
3275
3506
  */
3276
3507
  _emitAssistantRoomProgress(roomId) {
3508
+ // 이벤트 계약은 종전 그대로 둔다 — stream 목록은 getter 전용(이벤트로 매 틱 배열을 실어 보내면
3509
+ // DELTA 마다 방 전체 스트림을 다시 만들어 흘리는 셈이다).
3510
+ const { streams, ...event } = this._assistantRoomProgressPayload(roomId);
3511
+ this.emit('assistantRoomProgress', event);
3512
+ }
3513
+
3514
+ /**
3515
+ * 방 단위 집계 payload 생성 — {@link #_emitAssistantRoomProgress} 와 {@link #getAssistantProgress} 의
3516
+ * <b>공통 SSOT</b>. 두 곳에서 각자 조립하면 "이벤트로 본 상태"와 "물어봐서 본 상태"가 갈린다.
3517
+ * @private
3518
+ */
3519
+ _assistantRoomProgressPayload(roomId) {
3277
3520
  const slots = this._assistantProgress.get(roomId);
3278
3521
  const alive = slots ? Array.from(slots.entries()) : [];
3279
3522
  if (alive.length === 0) {
3280
- this.emit('assistantRoomProgress', {
3523
+ return {
3281
3524
  roomId, active: false, streamCount: 0,
3282
- streamId: null, personaId: null, phase: null, label: null, text: null, textAvailable: true
3283
- });
3284
- return;
3525
+ streamId: null, personaId: null, phase: null, label: null, text: null, textAvailable: true,
3526
+ streams: []
3527
+ };
3285
3528
  }
3286
3529
  const [streamId, slot] = alive.reduce((a, b) => (b[1].order > a[1].order ? b : a));
3287
- this.emit('assistantRoomProgress', {
3530
+ return {
3288
3531
  roomId,
3289
3532
  active: true,
3290
3533
  streamCount: alive.length,
@@ -3293,8 +3536,43 @@ class ChatClient extends EventEmitter {
3293
3536
  phase: slot.phase,
3294
3537
  label: slot.phase ? (this._assistantPhaseLabels[slot.phase] || null) : null,
3295
3538
  text: slot.textAvailable ? (slot.text || null) : null,
3296
- textAvailable: slot.textAvailable
3297
- });
3539
+ textAvailable: slot.textAvailable,
3540
+ // stream 단위 UI(권장 (B))가 마운트 즉시 버블을 복원할 수 있도록 전부 싣는다.
3541
+ // 도착 순서(order)대로 — 소비자가 "가장 최근"을 다시 계산하지 않아도 되게 한다.
3542
+ streams: alive
3543
+ .sort((a, b) => a[1].order - b[1].order)
3544
+ .map(([id, s]) => ({
3545
+ streamId: id,
3546
+ personaId: s.personaId,
3547
+ phase: s.phase,
3548
+ label: s.phase ? (this._assistantPhaseLabels[s.phase] || null) : null,
3549
+ text: s.textAvailable ? (s.text || null) : null,
3550
+ textAvailable: s.textAvailable,
3551
+ resumed: s.resumed === true
3552
+ }))
3553
+ };
3554
+ }
3555
+
3556
+ /**
3557
+ * 방의 <b>현재</b> AI 진행 상태를 즉시 반환 (네트워크 호출 없음).
3558
+ *
3559
+ * <p>{@code assistantProgress} / {@code assistantRoomProgress} 는 transient 이벤트라, 방 화면을 나갔다
3560
+ * 들어오면 프론트 상태가 비어 있는데 <b>이미 지나간 이벤트는 다시 오지 않는다</b>. 마운트 시점에 이걸
3561
+ * 한 번 읽으면 다음 이벤트를 기다리지 않고 바로 그릴 수 있다 — 재입장 스냅샷(1 RTT)보다도 빠르다.</p>
3562
+ *
3563
+ * <p>반환 형태는 {@code assistantRoomProgress} 이벤트와 동일하고, stream 단위 UI 를 위해 {@code streams}
3564
+ * 배열이 추가된다. 진행 중인 것이 없으면 {@code active:false} + 빈 배열.</p>
3565
+ *
3566
+ * @param {string} roomId - 채팅방 ID
3567
+ * @returns {Object} { roomId, active, streamCount, streamId, personaId, phase, label, text, textAvailable, streams[] }
3568
+ *
3569
+ * @example
3570
+ * // 방 화면 마운트 시 — 이벤트를 기다리지 않고 즉시 복원
3571
+ * const p = client.chat.getAssistantProgress(roomId);
3572
+ * if (p.active) renderBubble(p);
3573
+ */
3574
+ getAssistantProgress(roomId) {
3575
+ return this._assistantRoomProgressPayload(roomId);
3298
3576
  }
3299
3577
 
3300
3578
  /**
@@ -52,6 +52,10 @@ const CHAT_DELEGATE_METHODS = [
52
52
  'isSubscribed',
53
53
  'startTyping',
54
54
  'stopTyping',
55
+ // AI 진행 표시 — 마운트 즉시 복원용 동기 조회 + 수동 재개. 여기 없으면 최상위 클라이언트에서
56
+ // 런타임 undefined 가 된다(타입 선언만 있고 배선이 없으면 타입이 거짓말을 한다).
57
+ 'getAssistantProgress',
58
+ 'resumeAssistantStreams',
55
59
  'getAssistants',
56
60
  'getRoomAiMeta',
57
61
  'rateAssistantMessage',
@@ -65,7 +69,16 @@ const CHAT_DELEGATE_METHODS = [
65
69
  'deactivateRoomPmPrompt',
66
70
  'getRoomPmPromptVersions',
67
71
  'activateRoomPmPromptVersion',
68
- 'previewRoomPmPrompt'
72
+ 'previewRoomPmPrompt',
73
+ // 투표 — 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 전부 채팅 서버 경유.
74
+ 'createVote',
75
+ 'getVotes',
76
+ 'getVote',
77
+ 'updateVote',
78
+ 'deleteVote',
79
+ 'closeVote',
80
+ 'submitVote',
81
+ 'uploadVoteImage'
69
82
  ];
70
83
 
71
84
  const WEBRTC_DELEGATE_METHODS = [
@@ -62,10 +62,12 @@ class ApiClient {
62
62
  * @param {Object} [options] - 옵션
63
63
  * @param {Object} [options.body] - 요청 본문
64
64
  * @param {Object} [options.params] - URL 파라미터
65
+ * @param {Object} [options.headers] - 이 요청에만 얹는 추가 헤더. 기본 헤더(Authorization 등)와 병합된다.
66
+ * 같은 이름이면 이 값이 이긴다 — 인증 헤더를 덮어쓰지 않도록 호출자가 주의한다.
65
67
  * @returns {Promise<Object>}
66
68
  */
67
69
  async request(method, path, options = {}) {
68
- const { body, params } = options;
70
+ const { body, params, headers } = options;
69
71
 
70
72
  // URL 빌드
71
73
  let url = `${this.baseUrl}${path}`;
@@ -92,7 +94,7 @@ class ApiClient {
92
94
 
93
95
  response = await fetch(url, {
94
96
  method,
95
- headers: this._getHeaders(),
97
+ headers: headers ? { ...this._getHeaders(), ...headers } : this._getHeaders(),
96
98
  body: body ? JSON.stringify(body) : undefined,
97
99
  signal: controller.signal
98
100
  });
@@ -152,10 +154,12 @@ class ApiClient {
152
154
  * @param {string} path - API 경로
153
155
  * @param {Object} [body] - 요청 본문
154
156
  * @param {Object} [params] - URL 파라미터
157
+ * @param {Object} [headers] - 이 요청에만 얹는 추가 헤더 (예: {@code X-Idempotency-Key}).
158
+ * 기존 호출자는 인자를 넘기지 않으므로 동작이 바뀌지 않는다.
155
159
  * @returns {Promise<Object>}
156
160
  */
157
- post(path, body, params) {
158
- return this.request('POST', path, { body, params });
161
+ post(path, body, params, headers) {
162
+ return this.request('POST', path, { body, params, headers });
159
163
  }
160
164
 
161
165
  /**