# Webhook転送 — hợp đồng gửi ra ngoài (bàn giao cho `linect-service`)

Task **#40044 「Webhook転送」**, branch web `ai-feature-40044-v2`.
Tài liệu này mô tả **phần web `sns-line` đã làm** và **phần job Java phải làm** để hai bên khớp nhau.

> ## ⚠ LỖI THỜI TỪ 2026-09-09 — phần mô tả hàng đợi KHÔNG còn đúng (#40763)
>
> Thiết kế đã chốt lại ở **#40475** (mô tả ticket JOB): **bỏ bảng hàng đợi
> `webhook_relay_queue`**. Job quét THẲNG 3 bảng nguồn (`callback_event`, `tag_history`,
> `friend_info_history`) qua cột cờ đồng bộ trên chính bảng đó, không qua bảng trung gian.
>
> Phía web đã gỡ toàn bộ nhánh sinh dòng hàng đợi (bug #40763): `WebhookRelayEnqueueService`,
> `WebhookNotifyEmitter`, model/repo `WebhookRelayQueue`, helper `emitWebhookNotifyTags` và
> các lời gọi trong `BotController@callbackWebHook` + `HelperService` đều đã bị xoá.
>
> **Còn đúng:** mục `webhook_relay_setting` (web ghi, job đọc) và mục `webhook_relay_error`
> (job ghi, web đọc — lưu ý `queue_id` là id BẢN GHI NGUỒN, xem #40762).
> **Không còn đúng:** mọi mục nói về `webhook_relay_queue` (§2 "Lấy việc từ queue", câu poll,
> `payload`, `SOURCE_GONE`, thứ tự deploy dựa trên queue).
>
> Chưa cập nhật lại toàn văn vì tên/ngữ nghĩa cột cờ đồng bộ còn đang lệch giữa hai bên:
> #40475 ghi `status_sync` trên **3** bảng, còn migration web `2026_09_05_000000` lại tạo
> `status_callback` trên **2** bảng và không đụng `callback_event`. Cần chốt với team Java rồi
> mới viết lại §2.

Web Laravel chỉ **INSERT vào queue table**; toàn bộ việc gửi HTTP (ký, retry, ghi lỗi) do
`linect-service` thực hiện theo Database Polling Model (D-JOB-01). **Laravel không gửi request nào ra ngoài.**

> Đổi bất kỳ mục nào trong tài liệu này = **breaking change liên hệ thống**. Web sẽ KHÔNG báo lỗi
> khi job verify sai — chỉ có khách phát hiện. Sửa thì cập nhật cả file này lẫn code hai bên.

---

## 1. Hai kênh — khác nhau ở đâu

| | Kênh 1 — 「LINE公式アカウントデータ転送」 | Kênh 2 — 「L Messageデータ転送」 |
|---|---|---|
| `webhook_relay_queue.channel` | `1` | `2` |
| Nguồn phát sinh | Webhook LINE (`BotController@callbackWebHook`) | Thao tác tag / friend info trong LME |
| URL đích | `webhook_relay_setting.endpoint_url` | `webhook_relay_setting.notify_endpoint_url` |
| Cờ bật | `is_enabled` | `notify_is_enabled` |
| Payload | **raw body LINE nguyên trạng** | JSON `{"events":[...]}` do LME dựng |
| **Ký `X-Lme-Signature`** | ❌ **KHÔNG ký, KHÔNG có secret** | ✅ **HMAC-SHA256, hex** |

Kênh LINE không ký là **quyết định của BA** (BR-02 / QA-spec-028 bản sửa, xác nhận lại trên Redmine
#40044 ngày 2026-09-03: 「phần webhook phía line không có Signing secret」). Đừng "tiện tay" ký luôn cho
đối xứng — bên nhận kênh LINE không được cấp secret nên sẽ verify fail.

---

## 2. Lấy việc từ queue

Bảng **`webhook_relay_queue`**:

| Cột | Kiểu | Ý nghĩa |
|---|---|---|
| `id` | bigint UNSIGNED PK | |
| `bot_id` | int | `bots.id` |
| `channel` | tinyint | `1` = LINE · `2` = LME |
| `callback_event_id` | int UNSIGNED NULL | **channel 1: BẮT BUỘC** — nguồn đọc payload. NULL với channel 2 |
| `delivery_id` | char(36) | UUID, gửi kèm `X-Lme-Delivery-Id`, **giữ nguyên qua mọi lần retry** |
| `event_type` | varchar(255) | channel 1: `callback_event.type` · channel 2: `tag` \| `friend_info` |
| `payload` | longtext NULL | **channel 1: NULL** · channel 2: JSON `{"events":[...]}` |
| `status` | tinyint | `0` chờ · `1` đang gửi · `2` thành công · `3` thất bại cuối cùng |
| `attempt_count` | tinyint | số lần đã thử, tối đa 3 |
| `next_attempt_at` | datetime NULL | thời điểm được phép thử lại |
| `locked_by` / `locked_at` | varchar(64) / datetime | chống 2 instance gửi trùng + phát hiện orphan lock |
| `created_at` / `processed_at` | datetime | |

Câu poll (có index `idx_webhook_relay_queue_poll` = `status, next_attempt_at, id`):

```sql
SELECT * FROM webhook_relay_queue
WHERE status = 0 AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())
ORDER BY id ASC LIMIT :batch;
```

Set `status = 1`, `locked_by`, `locked_at` **trước khi** gửi. Dòng `status = 1` có `locked_at` quá hạn
(đề xuất 5 phút) coi là mồ côi → đưa về `status = 0`.

### ⚠ Lấy payload theo `channel`

- **channel 1**: `SELECT request FROM callback_event WHERE id = :callback_event_id` — **KHÔNG** đọc cột
  `payload` của queue (nó NULL). Gửi **đúng chuỗi byte** đó, **không** decode-rồi-encode-lại JSON.
  Dòng `callback_event` không còn → `status = 3`, `error_code = SOURCE_GONE`, **không retry**.
- **channel 2**: đọc cột `payload` của chính dòng queue.

Kênh đang tắt hoặc URL rỗng → `status = 3`, `error_code = RELAY_DISABLED`, **không ghi** vào
`webhook_relay_error` (người dùng tự tắt thì không phải lỗi).

---

## 3. Signing secret — cách web lưu và cách job giải mã

Bảng **`webhook_relay_setting`**, 1 dòng / bot (`uk_webhook_relay_setting_bot` UNIQUE `bot_id`):

| Cột | Kiểu | Ghi chú |
|---|---|---|
| `signing_secret` | varchar(1024) NULL | ciphertext, **~244 ký tự** |
| `secret_generated_at` | datetime NULL | lần sinh/tái tạo gần nhất |

Secret **dùng chung cho cả bot**, nhưng **chỉ kênh 2 dùng tới**. Plaintext = `whsec_` + 32 ký tự base62
⇒ **luôn đúng 38 ký tự**.

### 3.1 Cơ chế mã hoá

**Laravel 5.5 `Crypt::encrypt()` — AES-256-CBC + `APP_KEY`** (`config/app.php` → `'cipher' => 'AES-256-CBC'`).

> **KHÔNG phải `TextUtils.encrypt` (AES/ECB + `Constants.SECURITY_KEY`) của `linect-service`** — khác cả
> mode lẫn key, dùng nhầm sẽ không giải được.
>
> **KHÔNG phải `Crypt::encryptString`**: code dùng `Crypt::encrypt()`, hàm này chạy `serialize()`
> **trước** khi mã hoá ⇒ sau khi giải AES còn **một lớp PHP-serialize** phải bóc.

Cấu trúc: `base64( json{"iv","value","mac"} )`

```json
{ "iv":    "5T7mM350osgFOzRupjYNPg==",   // base64, 16 byte
  "value": "QBlm6gy4p7HrmPUUzLzr0yJ5...", // base64 ciphertext
  "mac":   "2663846841b7c55bd4375ceb544f5c4d45423bd4f25128cfb8e581f180d4d314" }  // HEX
```

Sau khi giải AES, **trước** khi bóc serialize: `s:38:"whsec_2tDNnnE9Uk6lAsbIdMXrxtszW1i3Cij4";`

### 3.2 Các bước giải mã (job Java)

1. `APP_KEY` trong `.env` dạng `base64:xxxx` → **bỏ tiền tố `base64:`** rồi base64-decode → đúng **32 byte**.
2. base64-decode cột `signing_secret` → parse JSON.
3. **Verify MAC trước khi giải mã**: `hex(HmacSHA256(iv_b64 + value_b64, key))` phải khớp `mac`.
   Nối **hai chuỗi base64 nguyên văn** (không decode) — đây là cách Laravel tính.
4. `AES/CBC/PKCS5Padding`, key 32 byte, `iv = base64Decode(iv)`, dữ liệu `base64Decode(value)`.
5. Bóc lớp serialize: khớp `^s:(\d+):"(.*)";$` → group 2 là secret. Vì độ dài cố định, có thể dùng
   `^s:38:"(whsec_[0-9A-Za-z]{32})";$` và **fail rõ ràng** nếu không khớp (đừng đoán).

```java
// Rút gọn — bỏ try/catch và validate cho dễ đọc
byte[] key = Base64.getDecoder().decode(appKey.substring("base64:".length()));

JsonNode p  = mapper.readTree(Base64.getDecoder().decode(cipherTextColumn));
String ivB64 = p.get("iv").asText(), valB64 = p.get("value").asText(), mac = p.get("mac").asText();

Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(key, "HmacSHA256"));
String calc = Hex.encodeHexString(hmac.doFinal((ivB64 + valB64).getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(calc.getBytes(), mac.getBytes())) throw new IllegalStateException("MAC mismatch");

Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
       new IvParameterSpec(Base64.getDecoder().decode(ivB64)));
String serialized = new String(c.doFinal(Base64.getDecoder().decode(valB64)), StandardCharsets.UTF_8);

Matcher m = Pattern.compile("^s:(\\d+):\"(.*)\";$", Pattern.DOTALL).matcher(serialized);
if (!m.matches()) throw new IllegalStateException("unexpected PHP serialize payload");
String secret = m.group(2);   // whsec_...
```

⚠ **`APP_KEY` phải giống hệt bản web đang chạy.** Đổi `APP_KEY` ⇒ secret cũ không giải được (web
không sập, chỉ log lỗi và coi như chưa có secret) ⇒ phải 「再生成」 và cập nhật lại phía khách.

⚠ **Đừng cache secret lâu**: Admin bấm 「再生成」 là secret cũ **hết hiệu lực ngay, không ân hạn**
(QA-spec-017). Đọc lại theo `secret_generated_at` hoặc cache TTL ngắn.

---

## 4. Request gửi ra ngoài

| | Kênh 1 (LINE) | Kênh 2 (LME) |
|---|---|---|
| Method | `POST` | `POST` |
| URL | `endpoint_url` | `notify_endpoint_url` |
| `Content-Type` | `application/json` | `application/json` |
| `X-Lme-Delivery-Id` | ✅ `delivery_id` | ✅ `delivery_id` |
| `X-Lme-Signature` | ❌ không gắn | ✅ **hex** (mục 4.1) |
| `X-Lme-Timestamp` | ❌ | ✅ (mục 4.1) |
| Body | raw body LINE nguyên văn | `payload` nguyên văn |

### 4.1 `X-Lme-Signature` — **HEX** (chốt 2026-09-03)

```
X-Lme-Signature = lowercase_hex( HMAC-SHA256( body_bytes, secret_plaintext ) )
```

- **Hex thường, 64 ký tự**, KHÔNG base64, KHÔNG có tiền tố `sha256=`.
- Khoá HMAC = **secret plaintext** (`whsec_…`, 38 ký tự) dạng **UTF-8 bytes**, không phải ciphertext.
- Ký trên **đúng chuỗi byte sẽ gửi đi**. Serialize lại JSON trước khi ký = chữ ký lệch.
- `X-Lme-Timestamp` = epoch millis lúc gửi, để bên nhận từ chối request quá cũ.

Cùng một body, hai encoding khác hẳn nhau — nhầm là verify fail 100%:

```
body   : {"events":[{"lmessage_friend_id":1,"line_friend_id":"U1","type":"tag","action":"add","tag_ids":[7]}]}
hex    : 6a82416c1116d18b97677e7412eecccc1d32bd8df539beb2ee0baeb1dc211d5c   ← DÙNG CÁI NÀY
base64 : aoJBbBEW0YuXZ350Eu7MzB0yvY31Ob6y7guusdwhHVw=
```

> Căn cứ chọn hex: đề xuất TA ở QA-002 và **tiền lệ trong chính repo** —
> `app/Services/ApiJobSignatureService.php:91` dùng `hash_hmac('sha256', $data, $secret)` (không có
> tham số `$binary = true` ⇒ hex) rồi so bằng `hash_equals`.
>
> ⏳ **Còn phải chốt**: hiện ký trên **body thô**. Nếu muốn chống replay chặt hơn thì ký trên
> `timestamp + "\n" + body` như `ApiJobSignatureService` — **quyết định trước khi phát hành tài liệu
> cho khách**, đổi sau là breaking change với mọi bên nhận.

### 4.2 Body kênh 2 — hợp đồng 9 field

`{"events":[ … ]}`, tối đa **1.000 phần tử/request** (`WebhookRelayQueue::NOTIFY_EVENTS_PER_ROW`).

| Field | Kiểu | Bắt buộc | Chỉ có khi |
|---|---|---|---|
| `lmessage_friend_id` | integer | ✅ | |
| `line_friend_id` | string | ✅ | |
| `type` | string | ✅ | `tag` \| `friend_info` |
| `action` | string | ✅ | `tag`: `add`,`remove` · `friend_info`: `set`,`clear`,`today`,`point_replace`,`point_plus`,`point_minus`,`point_clear` |
| `tag_ids[]` | array\<integer\> | có điều kiện | `type = tag` |
| `field_id` | integer | có điều kiện | `type = friend_info` — built-in ID âm, custom ID dương |
| `value` | string | có điều kiện | `friend_info`, **trừ** `clear`/`today`/`point_clear` |
| `option_id` | integer | có điều kiện | `set` field kiểu SELECT |

**Không thêm field nào ngoài 9 field trên** (kể cả `bot_id`, `timestamp`, `event_id`) — bên nhận đã có
tài liệu này, field lạ = sai hợp đồng.

### 4.3 Đánh giá thành/bại

**HTTP 2xx = thành công**, kết thúc, **không đọc body**. `received = false` kèm 2xx **vẫn là thành công**,
không retry (BR-15). Body không phải JSON / rỗng → bỏ qua.

---

## 5. Retry, timeout, ghi lỗi

- **Timeout 15 giây** (connect + read) — QA-004.
- **Retry tối đa 3 lần**, **giãn cách cố định 1 phút** (`next_attempt_at = now() + 1 phút`) — BR-21 / QA-005.
- **Chỉ khi cả 3 lần đều fail** mới INSERT `webhook_relay_error` ⇒ một sự cố = **một dòng**, không nhân dòng theo số lần retry.
- **KHÔNG auto-disable kênh** dù lỗi kéo dài (BR-25 / QA-spec-009). Vẫn tăng `consecutive_failure_count` /
  `notify_consecutive_failure_count` cho metric, nhưng **không đổi** `is_enabled`; hai cột
  `auto_disabled_at` / `notify_auto_disabled_at` **không ghi** ở Phase 1.
- **Không rate limit** (QA-010). **Không đảm bảo thứ tự** (QA-006).
- **Chống SSRF lúc gửi**: URL đã validate lúc lưu, nhưng DNS đổi được sau đó (DNS rebinding) ⇒ job
  **phải kiểm lại**: chặn `127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, IPv6 loopback/ULA;
  **chỉ cổng 443**; **không đi theo redirect**.
- **Bỏ qua bot** `is_deleted = 1` hoặc hết hạn hợp đồng quá 7 ngày (QA-011) — cấu hình vẫn giữ trong DB.
- **Không log body** (chứa dữ liệu người dùng): log lỗi chỉ ghi `delivery_id` + `error_code`.

### Bảng `webhook_relay_error` — job ghi, web chỉ đọc

| Cột | Kiểu | Ghi chú |
|---|---|---|
| `bot_id` | int | |
| `channel` | tinyint | **bắt buộc đúng** — màn lọc lỗi theo kênh (BR-19) |
| `queue_id` | bigint UNSIGNED NULL | **id bản ghi ở BẢNG NGUỒN** (#40762): `callback_event.id` với kênh 1; `tag_history.id` / `friend_info_history.id` với kênh 2. KHÔNG phải `webhook_relay_queue.id` |
| `occurred_at` | datetime | cột sắp xếp chính |
| `event_type` | varchar(255) | |
| `error_code` | varchar(64) | **enum bên dưới** |
| `http_status` | smallint NULL | mã HTTP phía nhận trả về. NULL = không nhận được response → UI hiện `—`. Ngoại lệ: `TIMEOUT` ghi **408** (job tự điền, xem enum bên dưới) |

`error_code` phải nằm trong enum web đang map ra tiếng Nhật
(`app/Models/WebhookRelayError.php::ERROR_MESSAGES`); mã lạ rơi hết về nhóm 「その他のエラー」:

| `error_code` | Điều kiện | `http_status` |
|---|---|---|
| `CONNECT_FAILED` | không mở được kết nối / DNS lỗi | NULL |
| `NOT_FOUND` | HTTP 404 | 404 |
| `UNAUTHORIZED` | HTTP 401 / 403 | 401 hoặc 403 |
| `TIMEOUT` | quá thời gian chờ | **408** — job TỰ ĐIỀN, xem ghi chú bên dưới |
| `SERVER_ERROR` | HTTP 5xx (trừ 503) | mã thật |
| `UNAVAILABLE` | HTTP 503 | 503 |
| `OTHER` | ngoài các nhóm trên | mã thật nếu có |

> **`TIMEOUT` ghi `http_status = 408`** (chốt 2026-09-09). Đây là mã **job TỰ ĐIỀN**, KHÔNG phải mã do
> phía nhận trả về — hết thời gian chờ nghĩa là không có response nào cả. Chọn 408 (Request Timeout) để
> cột 「エラーコード」 trên màn 「転送エラー」 hiển thị được một giá trị thay vì `—`, giúp phân biệt nhanh
> "quá hạn chờ" với "không mở nổi kết nối".
>
> Hệ quả cần biết khi đọc dữ liệu: sau thay đổi này `http_status = 408` **không còn đồng nghĩa** với
> "phía nhận trả về 408". Muốn tách hai trường hợp thì lọc theo `error_code` (`TIMEOUT` = ta hết giờ chờ,
> `OTHER` + 408 = phía nhận thật sự trả 408), đừng lọc theo mình `http_status`.
> `CONNECT_FAILED` vẫn giữ NULL vì kết nối còn không mở được.
>
> ⚠ Chuỗi hiển thị của `TIMEOUT` hiện là 「タイムアウト（**10**秒）」 (lấy nguyên văn từ design) trong khi
> timeout thật đã chốt **15 giây**. Mâu thuẫn này **đang chờ BA** (C-05) — job cứ ghi `TIMEOUT`, phần chữ
> hiển thị sửa ở web sau khi có answer.

---

## 6. Bật/tắt & thứ tự triển khai

Web có 2 cờ ENV, **mặc định `false`**: `WEBHOOK_RELAY_ENABLED` (kênh 1) và
`WEBHOOK_RELAY_NOTIFY_ENABLED` (kênh 2). Thứ tự an toàn: migration → **deploy job Java** → deploy web →
bật cờ cho vài bot thử.

Deploy web trước mà job chưa có: queue chỉ tích dòng `status = 0`, **không mất dữ liệu**, không ảnh hưởng
nghiệp vụ — nhưng tab 「転送エラー」 sẽ luôn rỗng và không có gì được gửi đi.

---

## 7. Còn tồn — đọc trước khi code

| # | Nội dung | Chờ ai |
|---|---|---|
| 1 | Ký trên body thô hay `timestamp + "\n" + body` (mục 4.1) | Leader Dev |
| 2 | Chuỗi 「タイムアウト（10秒）」 vs timeout 15s | BA (C-05) |
| 3 | Quy tắc format `value` + quy ước dấu `field_id` — bám đúng hàm `/do_action` đang dùng, **không viết lại** | QA-015 / REQ-006 |
| 4 | Web mới phát event `tag`; **`friend_info` chưa có call site** (`WebhookNotifyEmitter::emitFriendInfoEvents` chưa được gọi) ⇒ job sẽ chưa bao giờ nhận `type = friend_info` cho tới khi web bổ sung | team web |
| 5 | Thứ tự event kênh 2: `set` rồi `clear` tới sai thứ tự sẽ để lại trạng thái sai ở hệ thống khách (QA-016) | Leader Dev |
