> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs.vpms.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 물리 장치 이벤트 로그 클라이언트 가이드

> 키오스크/CCU/카드단말 등 물리 장치가 audit 파이프라인에 이벤트를 안전하게 실어 보내기 위한 클라이언트·수신 서비스 계약

대상 독자: 키오스크/CCU/카드단말 등 **장치 클라이언트 개발자**, 그리고 장치를 인증·수신하는 **서비스(키오스크 API, 디바이스 게이트웨이) 개발자**.

기존 audit 자동 캡처 체계(Prisma extension + Mutation 자동 래핑, [ADR-0007](../adr/0007-audit-outbox-sqs-transport) 참고)는 core-svc의 GraphQL mutation과 DB 쓰기를 기준으로 동작한다. 물리 장치가 보내는 이벤트는 이 경로를 그대로 타지 않는 경우가 많아 — 화면 진입처럼 DB 쓰기가 없는 사용자 행위, 카드단말 불량처럼 core-svc mutation이 아닌 이벤트 — 별도 계약이 필요하다. 이 계약의 구체적 구현이 `subscription-svc`의 `reportDeviceEvents`다(§2).

## 1. 이벤트 분류 2종

| 분류           | category        | 설명                   | 예시                                                                                                                            |
| ------------ | --------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| 사용자 행위 이벤트   | `BEHAVIOR`      | 게스트가 장치를 조작한 행위. 고볼륨 | `kiosk.screen.entered`, `kiosk.checkin.started`, `kiosk.checkin.completed`, `kiosk.payment.attempted`, `kiosk.payment.failed` |
| 장치 상태/헬스 이벤트 | `DEVICE_HEALTH` | 장치 자체의 상태 변화, 장애     | `device.card_reader.fault`, `device.printer.paper_out`, `ccu.connection.lost`, `ccu.door.opened`                              |

`category`와 `severity`(`INFO`/`WARN`/`ERROR`/`CRITICAL`)는 별도 분류 필드로 둔다 — `actionType=EVENT` 하나로 뭉뚱그리지 않는다. 화면 진입처럼 고볼륨인 `BEHAVIOR` 이벤트는 보존기간과 기본 조회 노출 정책(예: 기본 조회에서 제외, 별도 조회에서만 노출)을 `DEVICE_HEALTH`와 분리할 수 있어야 하기 때문이다.

## 2. 수집 경로 & 신뢰 경계 원칙

**장치는 audit-svc에 직접 쓰지 않는다.** 수신 서비스는 `apps/subscription-svc`에 신설하는 GraphQL 배치 mutation **`reportDeviceEvents`** 다 — 같은 서비스가 이미 `publishAccommodationNotification`으로 업장 알림을 처리하고 있고, 장치 인증 접근 제어(`isKioskSelf`/`isCcuSelf`, `apps/subscription-svc/src/graphql/helpers/accessControl.ts`)도 거기 갖춰져 있으므로 새 서비스를 만들지 않고 그 위에 얹는다.

**용도 구분**: `publishAccommodationNotification`은 프런트/키오스크 화면에 실시간으로 보여줄 **알림**(pubsub 구독 대상)이고, `reportDeviceEvents`는 화면에 보여주지 않는 **이벤트 로그**(audit 파이프라인 적재 전용)다. 알림이 필요 없는 화면 진입·장치 헬스 이벤트를 알림 mutation에 얹지 않는다.

호출 흐름:

1. 키오스크/CCU가 게이트웨이를 경유해 장치 인증 컨텍스트(`context.extra.user.kioskId` 또는 `.ccuId`)를 갖고 `reportDeviceEvents`를 호출한다.
2. resolver가 `isKioskSelf`/`isCcuSelf`로 요청의 `deviceId`가 인증된 장치 본인인지 검증한다 — `requestAdultAuthentication`(`apps/subscription-svc/src/graphql/resolver/notification.ts:223`)이 쓰는 것과 동일한 패턴: `isKioskSelf(context.extra, { kioskId })`가 거짓이면 즉시 거부.
3. `accommodationId`는 **요청 바디 값을 그대로 믿지 않는다.** `requestAdultAuthentication`이 `kioskAccommodationId !== accommodationId`일 때 `KIOSK_ACCOMMODATION_MISMATCH`로 거부하듯, subscription-svc가 인증된 장치의 등록 정보(`context.extra.user.accommodationId`)에서 조회한 값과 요청값을 대조해 불일치 시 거부한다.
4. subscription-svc가 `actorType`/`contextId` 등을 enrich한 뒤 기존 audit 파이프라인(`publishAuditLogBatch`)으로 발행한다.

* `accommodationId`, `kioskId` 등 장치 신원에 관한 값은 **장치가 주장하는 값을 신뢰하지 않는다.** resolver가 인증 컨텍스트에서 서버 측이 직접 조회해 채운다.
* 장치가 보낸 페이로드의 `accommodationId`/`kioskId` 필드는 참고용으로만 취급하고, 서버가 조회한 값과 다르면 요청을 거부한다.

## 3. 클라이언트 전송 계약

장치가 이벤트를 보낼 때 요청에 실어야 하는 필드:

| 필드             | 필수             | 설명                                                                                                                                         |
| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `eventId`      | ✅              | 장치가 생성하는 ULID/UUID. 오프라인 재전송 시 서버 측 멱등 키로 쓰인다                                                                                              |
| `occurredAt`   | ✅              | 장치 로컬 시각(ISO 8601). 서버는 수신 시각을 `receivedAt`으로 별도 기록하고 둘의 차이(클럭 스큐)를 모니터링한다                                                                 |
| `eventType`    | ✅              | 이벤트 카탈로그에 등록된 안정적인 점(dot) 표기 이름 (예: `kiosk.screen.entered`)                                                                                |
| `category`     | ✅              | `BEHAVIOR` \| `DEVICE_HEALTH`                                                                                                              |
| `severity`     | ✅ (기본값 `INFO`) | `INFO` \| `WARN` \| `ERROR` \| `CRITICAL`                                                                                                  |
| `deviceId`     | ✅              | 장치 고유 식별자 (예: kioskId)                                                                                                                     |
| `sessionId`    | 선택             | 게스트 세션/체크인 플로우 식별자                                                                                                                         |
| `resourceRefs` | 선택             | 알고 있는 대상 리소스 참조 (`reservationId`, `roomId` 등)                                                                                              |
| `payload`      | 선택             | 이벤트별 최소 필드만. 10KB 이내 권장, 초과 시 **`rejected(PAYLOAD_TOO_LARGE)`로 명시 거부**(절단 아님 — [ADR-0007 §2.4](../adr/0007-audit-outbox-sqs-transport) 참고) |

### 금지 필드 (payload에 절대 포함 금지)

카드번호/트랙데이터, 비밀번호, 인증 토큰 원문, 주민등록번호 등. 서버 측 캡처 계층도 재귀 마스킹(`libs/audit/src/extension/mask.ts`)을 거치지만, **클라이언트가 애초에 보내지 않는 것이 원칙**이다 — 마스킹은 마지막 방어선이지 전송 허용 기준이 아니다.

### 배치 전송 API — `reportDeviceEvents`

오프라인 버퍼 플러시를 위해 단건이 아닌 **배열 + 부분 성공 응답** 형태를 기본으로 제공한다. `subscription-svc`의 GraphQL mutation이다.

```graphql theme={null}
type Mutation {
  reportDeviceEvents(input: ReportDeviceEventsInput!): ReportDeviceEventsResult!
}

input ReportDeviceEventsInput {
  kioskId: String   # kioskId, ccuId 중 정확히 하나만 제공 — isKioskSelf/isCcuSelf 검증 대상
  ccuId: String
  accommodationId: String!  # 서버가 인증 컨텍스트와 대조, 불일치 시 거부
  events: [DeviceEventInput!]!
}

input DeviceEventInput {
  eventId: String!
  occurredAt: DateTime!
  eventType: String!
  category: DeviceEventCategory!   # BEHAVIOR | DEVICE_HEALTH
  severity: DeviceEventSeverity! = INFO
  deviceId: String!
  sessionId: String
  resourceRefs: JSON
  payload: JSON
}

type ReportDeviceEventsResult {
  accepted: [String!]!      # eventId 목록
  duplicated: [String!]!    # 이미 처리된 eventId — 클라이언트는 accepted와 동일하게 버퍼에서 제거
  rejected: [RejectedDeviceEvent!]!
}

type RejectedDeviceEvent {
  eventId: String!
  reason: String!
}
```

## 4. 오프라인/재시도 시맨틱

* **로컬 버퍼**: 장치는 네트워크 단절 시 이벤트를 로컬에 버퍼링한다. 버퍼는 상한(건수)과 보존 시간을 둔다.
* **재시도**: 지수 백오프 + jitter로 재전송한다.
* **전달 보장**: at-least-once를 전제한다 — 서버가 `eventId` 기준 dedup을 책임지므로 클라이언트는 중복 전송을 두려워하지 않아도 된다.
* **전송 우선순위**: 버퍼가 가득 찼거나 네트워크가 간헐적일 때 우선순위 순서로 플러시한다.
  1. 결제/보안 관련 이벤트 (`kiosk.payment.*`, `device.card_reader.*`)
  2. 장치 헬스 이벤트
  3. 화면 이동 등 저우선순위 `BEHAVIOR` 이벤트
* **버퍼 초과 시 드롭 정책**: 오래된 `BEHAVIOR` 이벤트부터 드롭한다. `DEVICE_HEALTH`와 결제 관련 이벤트는 최대한 보존한다.

## 5. actorType 매핑 (확정)

| 이벤트 주체                         | actorType |
| ------------------------------ | --------- |
| 키오스크 상 게스트 행위 (체크인, 결제 등)      | `KIOSK`   |
| 장치 자체 상태 변화 (카드단말 불량, 용지 부족 등) | `DEVICE`  |

`AuditActorType`(`libs/audit/src/context/audit-context.ts`)에 `DEVICE`를 추가한다. `reportDeviceEvents`는 이벤트별 `category`를 보고 enrich 단계에서 `BEHAVIOR` → `KIOSK`, `DEVICE_HEALTH` → `DEVICE`로 매핑해 채운다.

## 6. 서버측 수신 서비스 체크리스트 (`subscription-svc` · `reportDeviceEvents`)

1. **장치 신원 검증** — resolver가 인증 이전에 `kioskId`/`ccuId`가 **정확히 1개**만 제공됐는지 확인한다. 둘 다 없거나 둘 다 있으면 `INVALID_DEVICE_IDENTITY`로 즉시 거부.
2. **장치 인증** — `isKioskSelf`/`isCcuSelf`(`accessControl.ts`)로 요청의 `kioskId`/`ccuId`가 인증 컨텍스트(`context.extra.user`)의 장치 본인인지 검증. 불일치 시 `KIOSK_AUTH_REQUIRED` 등으로 즉시 거부.
3. **accommodationId 대조** — 인증된 장치의 등록 `accommodationId`와 요청의 `accommodationId`를 대조, 불일치 시 `KIOSK_ACCOMMODATION_MISMATCH`로 거부(`requestAdultAuthentication`과 동일 패턴).
4. **스키마 검증** — `eventId`/`occurredAt`/`eventType`/`category` 필수 필드와 `eventType` 카탈로그 존재 여부를 확인.
5. **eventId 멱등** — 이미 처리된 `eventId`는 `accepted`가 아니라 `duplicated`로 응답하고 재처리하지 않는다.
6. **Enrich** — `accommodationId`(§2)/`actorType`(§5)/`contextId`를 서버가 직접 채운다. 장치가 보낸 값은 무시하거나 대조용으로만 쓴다.
7. **Audit 파이프라인 발행** — `publishAuditLogBatch`(`@vpms-cluster/audit`)를 호출한다. [ADR-0007](../adr/0007-audit-outbox-sqs-transport)의 outbox 전환은 이 호출의 내부 구현만 바뀌므로 `reportDeviceEvents` 쪽 코드 변경은 없다.

## 7. 이벤트 예시

### 예시 1 — 화면 진입 배치 (BEHAVIOR)

`reportDeviceEvents` mutation 호출 변수:

```json theme={null}
{
  "input": {
    "kioskId": "kiosk-gangnam-01",
    "accommodationId": "acc-01J7X1...",
    "events": [
      {
        "eventId": "01J8X9K3QZ7R2M4N5P6Q7R8S9T",
        "occurredAt": "2026-07-10T09:15:32.104+09:00",
        "eventType": "kiosk.screen.entered",
        "category": "BEHAVIOR",
        "deviceId": "kiosk-gangnam-01",
        "sessionId": "sess-01J8X9K3QZ",
        "payload": { "screenName": "checkin-guest-info" }
      },
      {
        "eventId": "01J8X9K3R18F3N5P6Q7R8S9T0U",
        "occurredAt": "2026-07-10T09:16:01.552+09:00",
        "eventType": "kiosk.checkin.started",
        "category": "BEHAVIOR",
        "deviceId": "kiosk-gangnam-01",
        "sessionId": "sess-01J8X9K3QZ",
        "resourceRefs": { "reservationId": "01J7Y2..." },
        "payload": {}
      }
    ]
  }
}
```

### 예시 2 — 카드단말 불량 (DEVICE\_HEALTH)

```json theme={null}
{
  "input": {
    "kioskId": "kiosk-gangnam-01",
    "accommodationId": "acc-01J7X1...",
    "events": [
      {
        "eventId": "01J8X9M5V2W4X6Y8Z0A1B2C3D4",
        "occurredAt": "2026-07-10T09:20:11.884+09:00",
        "eventType": "device.card_reader.fault",
        "category": "DEVICE_HEALTH",
        "severity": "ERROR",
        "deviceId": "kiosk-gangnam-01",
        "payload": {
          "faultCode": "READER_TIMEOUT",
          "reader": "MSR-206"
        }
      }
    ]
  }
}
```

주의: 두 예시 모두 카드번호/트랙데이터를 포함하지 않는다. 카드단말 불량 페이로드는 장애 코드와 리더 식별자만 담고, 실제 카드 데이터는 절대 포함하지 않는다(§3 금지 필드 참고).
