# channel-service 与 mci-server 双向集成设计

> **需求来源**：继续完成 pywechat 客户端功能，打通 Python 渠道服务与 Java 消息中台的双向通信。  
> **日期**：2026-07-10  
> **状态**：待实现  
> **关联文档**：`docs/architecture/2026-07-06-message-customer-interaction-design.md`

---

## 1. 目标与范围

### 1.1 目标

让 `channel-service`（Python FastAPI）与 `mci-server`（Java Spring Boot）完成双向 HTTP 集成：

- **下行（mci-server → channel-service）**：mci-server 继续通过现有 REST API 调用 channel-service 的发送、登录控制等指令。
- **上行（channel-service → mci-server）**：channel-service 把心跳、登录/在线状态变更、发送结果、异常事件持久化到本地 SQLite，并由后台 worker 可靠地推送到 mci-server。
- **可靠性**：服务重启不丢事件；网络异常按指数退避重试；超次进死信；本地事件保留 10 天后自动清理。

### 1.2 范围

- **channel-service**：新增事件模型、SQLite 持久化、后台事件发布 worker、mci-server HTTP 客户端、配置项扩展、API 行为调整。
- **mci-server**：新增 `/api/v1/channel-account/event` 接收端点，处理心跳/状态/发送结果事件并更新 `mci_channel_account`、`mci_send_record`。
- **不在本期范围**：
  - 真实 pywechat/pywinauto 接入（仍为 mock）。
  - RabbitMQ/MQ 等外部消息总线。
  - 入站消息从 channel-service 回传（本期只传运行态事件和发送结果）。

---

## 2. 整体架构

```
┌─────────────────────────────────────────────────────────────┐
│                        mci-server                            │
│  Java 21 + Spring Boot 4.1.x                                │
│  POST /api/v1/channel-account/event                         │
└───────────────────────┬─────────────────────────────────────┘
                        │  HTTP 上行事件
                        ▼
┌─────────────────────────────────────────────────────────────┐
│                     channel-service                          │
│  Python 3.11 + FastAPI                                       │
│                                                              │
│  ┌─────────────┐    ┌──────────────────┐    ┌────────────┐ │
│  │  HTTP API   │───►│  业务操作 + 事件   │───►│ outbound_  │ │
│  │             │    │  写入 SQLite      │    │ events     │ │
│  └─────────────┘    └──────────────────┘    └─────┬──────┘ │
│                                                   │         │
│                              ┌────────────────────┘         │
│                              ▼                              │
│                     ┌─────────────────┐                     │
│                     │ EventPublisher  │                     │
│                     │ 单线程 worker   │                     │
│                     └────────┬────────┘                     │
│                              │                              │
│                              ▼                              │
│                     ┌─────────────────┐                     │
│                     │ mci_server_client│                    │
│                     │ HTTP POST        │                    │
│                     └─────────────────┘                     │
└─────────────────────────────────────────────────────────────┘
```

核心原则：**状态变更/发送操作与事件写入在同一个 SQLite 事务内完成**，保证原子性。

---

## 3. channel-service 设计

### 3.1 新增模块

| 模块 | 路径 | 职责 |
|------|------|------|
| 事件模型 | `app/models/event.py` | `OutboundEvent` 数据类、事件类型枚举、payload schema |
| 事件存储 | `app/repositories/event_store.py` | SQLite 建表、CRUD、事务、清理 |
| 事件发布 | `app/services/event_publisher.py` | 后台单线程 worker、重试、死信 |
| mci-server 客户端 | `app/clients/mci_server_client.py` | HTTP POST 到 mci-server |
| 配置扩展 | `app/config.py` | 新增事件存储、重试、清理相关配置 |

### 3.2 SQLite 事件表 `outbound_events`

```sql
CREATE TABLE IF NOT EXISTS outbound_events (
    id TEXT PRIMARY KEY,
    event_type TEXT NOT NULL,
    payload TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    attempt_count INTEGER NOT NULL DEFAULT 0,
    next_retry_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_error TEXT
);

CREATE INDEX IF NOT EXISTS idx_status_next_retry
    ON outbound_events(status, next_retry_at);

CREATE INDEX IF NOT EXISTS idx_created_at
    ON outbound_events(created_at);
```

字段说明：

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | TEXT PK | UUID v4，即 `event_id` |
| `event_type` | TEXT | `heartbeat` / `login_status_changed` / `online_status_changed` / `send_result` / `error` |
| `payload` | TEXT | JSON 字符串，见第 3.3 节 |
| `status` | TEXT | `pending` / `retrying` / `delivered` / `dead_letter` |
| `attempt_count` | INTEGER | 已尝试次数，上限 `max_retry` |
| `next_retry_at` | TIMESTAMP | 下次重试时间；`pending` 事件为创建时间 |
| `created_at` | TIMESTAMP | 创建时间，用于 10 天清理 |
| `updated_at` | TIMESTAMP | 更新时间 |
| `last_error` | TEXT | 最近一次失败摘要 |

### 3.3 事件 payload 统一结构

所有事件统一外层：

```json
{
  "event_id": "evt_20260710_xxx",
  "event_type": "heartbeat",
  "account_id": "wx-bot-01",
  "tenant_code": "rescue",
  "timestamp": "2026-07-10T10:00:00Z",
  "data": { }
}
```

`data` 按事件类型定义：

#### heartbeat

```json
{
  "login_status": "success",
  "online_status": "online",
  "risk_level": 10,
  "sent_count_today": 5,
  "server_host": "win-svr-01"
}
```

#### login_status_changed

```json
{
  "previous": "pending",
  "current": "success",
  "qr_code_url": "https://mock.pywechat.qrcode/...",
  "error_message": null
}
```

#### online_status_changed

```json
{
  "previous": "offline",
  "current": "online"
}
```

#### send_result

```json
{
  "task_id": "task_001",
  "record_id": 1001,
  "conversation_id": "room-001",
  "success": true,
  "message_id": "msg-123",
  "channel_result": {},
  "error": null
}
```

> 说明：`record_id` 由 mci-server 在调用 send API 时传入；channel-service 原样回传，用于 mci-server 更新 `mci_send_record`。

#### error

```json
{
  "operation": "send_text",
  "error_code": "ACCOUNT_OFFLINE",
  "error_message": "account wx-bot-01 not online"
}
```

### 3.4 API 行为变更

| API | 变更 |
|------|------|
| `POST /v1/wechat-personal/login/start` | 创建/更新账号、生成二维码，并在同一事务写入 `login_status_changed` 事件 |
| `POST /v1/wechat-personal/login/{id}/confirm` | 确认登录后，在同一事务写入 `login_status_changed` + `online_status_changed` |
| `POST /v1/wechat-personal/login/{id}/expire` | 二维码过期后，写入 `login_status_changed` 事件 |
| `POST /v1/wechat-personal/send/text` | 改为**异步受理**：立即返回 `{"accepted": true, "message_id": "..."}`；真实发送完成后写入 `send_result` 事件 |
| `POST /v1/wechat-personal/send/image` | 同上 |
| `POST /v1/heartbeat` | 更新本地状态，并在同一事务写入 `heartbeat` 事件 |
| `POST /v1/admin/retry-dead-letter`（新增） | 手动把 `dead_letter` 状态重置为 `retrying`，用于人工恢复 |

### 3.5 发送异步化与风控

当前仍为 mock 客户端，但接口行为需与真实 pywechat 一致：

1. `send_text` / `send_image` 收到请求后，先校验账号在线、风控等级。
2. 校验通过后返回 `accepted=true`，并立即写入 `send_result` 事件。
3. mock 场景下该事件直接表示成功；真实 pywechat 场景下，发送完成后用同一 `event_id` 更新 `send_result` 事件内容，或删除旧事件后写入最终结果事件。

**风控处理**：

- 风控等级 `risk_level >= 80` 的账号，发送请求直接返回失败，不进入重试。
- 因微信风控导致的发送失败（如 `error_code` 为 `RISK_CONTROL_BLOCKED`、`ACCOUNT_BANNED`），事件直接标记为 `dead_letter`，不再重试。
- 因网络超时、mci-server 不可达等**临时错误**，才进入指数退避重试。
- `sent_count_today` 每日重置，channel-service 在心跳中上报，mci-server 可据此做策略判断。

### 3.6 EventPublisher 后台 worker

- **单线程执行**：使用一个 `asyncio` 任务循环消费，避免并发导致 SQLite 锁竞争或事件乱序。
- **扫描频率**：每 5 秒一次。
- **每次拉取**：最多 100 条 `status IN ('pending','retrying') AND next_retry_at <= now()`，按 `next_retry_at` 升序。
- **推送目标**：`{mci_server_url}/api/v1/channel-account/event`。
- **成功**：`status='delivered'`，`attempt_count` 不变。
- **失败**：
  - `attempt_count += 1`
  - `next_retry_at = now + base_delay * 2^(attempt_count - 1)`（首次重试为 `base_delay`，第二次为 `2*base_delay`，依此类推）
  - `status='retrying'`
  - `last_error` 记录 HTTP 状态码或异常摘要
  - 若 `attempt_count >= max_retry`，则 `status='dead_letter'`
- **最大重试次数**：由配置 `max_retry` 控制，默认 `5`，上限 `10`（代码中硬上限，防止配置错误）。
- **服务启动**：从 `main.py` 启动时创建 worker；启动后自动恢复 `pending` / `retrying` 事件。
- **服务关闭**：优雅关闭时等待当前批次处理完成（超时 5 秒）。

### 3.7 数据清理

- 每天凌晨 3:00 启动清理任务（或每次 worker 循环顺带清理）。
- 删除 `status IN ('delivered','dead_letter')` 且 `created_at < CURRENT_TIMESTAMP - INTERVAL '10 days'` 的事件。
- 保留 `pending` / `retrying` 事件，无论创建时间。

### 3.8 配置项

```env
# mci-server 地址
MCI_SERVER_URL=http://localhost:8080/msg-platform

# SQLite 事件库路径
EVENT_STORE_PATH=./data/events.db

# 心跳间隔（秒）
HEARTBEAT_INTERVAL_SECONDS=30

# 最大重试次数（代码硬上限 10）
MAX_RETRY=5

# 基础重试延迟（秒）
BASE_RETRY_DELAY_SECONDS=5

# 事件保留天数
EVENT_RETENTION_DAYS=10

# 后台 worker 扫描间隔（秒）
EVENT_PUBLISH_INTERVAL_SECONDS=5
```

---

## 4. mci-server 设计

### 4.1 新增端点

#### POST `/api/v1/channel-account/event`

接收 channel-service 推送的统一事件。

**请求头**：

```http
Content-Type: application/json
```

**请求体**：见第 3.3 节统一 payload。

**处理逻辑**：

| event_type | 处理 |
|------------|------|
| `heartbeat` | 根据 `account_id` 查询 `mci_channel_account`；更新 `login_status`、`online_status`、`last_heartbeat`、`risk_level`、`sent_count_today` |
| `login_status_changed` | 更新 `mci_channel_account.login_status`；如果 `current=success` 则同时更新 `online_status=1` |
| `online_status_changed` | 更新 `mci_channel_account.online_status` |
| `send_result` | 根据 `record_id` 查询 `mci_send_record`；更新 `send_status`（`success=true` 则为 2，否则 3）、`finish_time`、`channel_result`；成功后调用 `SendCallbackService.sendCallback()` 向业务系统回调 |
| `error` | 记录告警日志，可扩展发送告警通知 |

**状态字段映射**（channel-service 字符串 → mci-server 整数）：

| channel-service 字段 | mci-server 字段 | 映射关系 |
|----------------------|-----------------|----------|
| `login_status` | `mci_channel_account.login_status` | `pending=0`, `scanning=0`, `success=1`, `expired=2`, `failed=3` |
| `online_status` | `mci_channel_account.online_status` | `offline=0`, `online=1`, `error=2` |

账号定位：使用 `tenant_code` + `account_id` 联合查询 `mci_channel_account`，确保租户隔离。

**幂等**：

- 使用 `event_id` 作为幂等键。
- 第一次收到时写入 `mci_channel_event` 去重表；重复事件直接返回 200。

**响应**：

```json
{
  "code": 0,
  "message": "ok",
  "data": null,
  "request_id": "..."
}
```

### 4.2 保持现有端点

- `POST /api/v1/channel-account/heartbeat`：保持兼容，但建议新接入走 `/event`。
- `POST /api/v1/channel-account/init`：继续调用 `WechatPersonalChannelAdapter`，由 adapter 调用 channel-service `/v1/wechat-personal/login/start`。

### 4.3 发送流程补齐

为了让 `send_result` 能回写到正确的 `mci_send_record`，需要在 mci-server 调用 channel-service 发送时传入 `record_id`：

1. `SendService.dispatchRecord(record)` 调用 `ChannelAdapter.sendMessage(request)`。
2. `SendMessageRequest.extra` 中带上 `record_id`。
3. `WechatPersonalChannelAdapter.sendMessage()` 把 `record_id` 放入 body，调用 channel-service `/v1/wechat-personal/send/text` 或 `/send/image`。
4. channel-service 异步完成后，通过 `/event` 回传 `send_result` 并携带 `record_id`。

### 4.4 数据库表扩展

#### 4.4.1 新增幂等表 `mci_channel_event`

用于防止 channel-service 重试导致重复处理。

| 字段 | 类型 | 说明 |
|------|------|------|
| `event_id` | VARCHAR PK | channel-service 事件 ID |
| `account_id` | VARCHAR | 账号 ID |
| `event_type` | VARCHAR | 事件类型 |
| `create_time` | DATETIME | 接收时间 |

索引：`CREATE INDEX idx_account_time ON mci_channel_event(account_id, create_time);`

清理策略：保留 30 天后删除（可配置）。

---

## 5. 错误处理与边界

| 场景 | 处理 |
|------|------|
| mci-server 不可达 | 事件留在 SQLite，worker 按指数退避重试 |
| 服务重启 | 启动时恢复 worker，继续推送 `pending` / `retrying` 事件 |
| payload 序列化失败 | 直接标记 `dead_letter`，不阻塞其他事件 |
| mci-server 返回业务错误（如账号不存在） | 视为失败重试；若连续失败达到上限，进 `dead_letter` |
| 微信风控/账号被封 | 对应 `error_code` 直接标记 `dead_letter`，不重试 |
| SQLite 磁盘满/损坏 | worker 记录错误并停止推送；监控告警 |
| 单条事件推送超时 | 使用 `httpx` timeout（默认 10s），超时即失败进入重试 |
| 重试次数达到上限 | `status='dead_letter'`，记录 `last_error`，等待人工处理或手动重试 |
| 事件堆积 | worker 每批最多 100 条；如果持续堆积，考虑后续改为批量 POST 或引入 MQ |

---

## 6. 测试策略

### 6.1 channel-service 单元测试

- `test_event_store.py`：验证 SQLite CRUD、事务、状态流转、清理。
- `test_event_publisher.py`：mock mci-server HTTP，验证重试、死信、幂等。
- `test_send_async.py`：验证 send API 改为异步受理后，事件正确写入 `outbound_events`。
- `test_heartbeat_event.py`：验证心跳接口产生事件。

### 6.2 mci-server 单元测试

- `ChannelAccountEventControllerTest`：使用 MockWebServer 或 `MockMvc` 接收 channel-service 事件，验证：
  - heartbeat 更新 `mci_channel_account`
  - send_result 更新 `mci_send_record`
  - event_id 幂等

### 6.3 联调测试

- docker-compose 启动 mci-server + channel-service。
- 调用 mci-server 发送 API。
- 验证 channel-service 收到指令、产生 send_result 事件、推回 mci-server。
- 验证 mci-server 更新 SendRecord 并向业务系统 callback_url 回调。

---

## 7. 部署与配置

### 7.1 channel-service 新增环境变量

```yaml
environment:
  MCI_SERVER_URL: http://mci-server:8080/msg-platform
  EVENT_STORE_PATH: /app/data/events.db
  HEARTBEAT_INTERVAL_SECONDS: "30"
  MAX_RETRY: "5"
  BASE_RETRY_DELAY_SECONDS: "5"
  EVENT_RETENTION_DAYS: "10"
  EVENT_PUBLISH_INTERVAL_SECONDS: "5"
```

### 7.2 数据卷

```yaml
volumes:
  - channel-service-data:/app/data
```

---

## 8. 后续扩展

- 入站消息从 channel-service 回传 mci-server（复用 `/event` 新增 `message_received` 类型）。
- 批量事件上报：把多条事件打包为一个 HTTP 请求，降低 mci-server 压力。
- 当事件量增大时，可考虑用 RabbitMQ 替代 SQLite + HTTP worker。
- 真实 pywechat 接入时，把 mock 客户端替换为 `pywechat`/`pywinauto` 驱动，事件模型保持不变。

---

*设计完成，待评审。*
