# channel-service 与 mci-server 双向集成 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 `channel-service`(Python)中实现本地 SQLite 持久化的事件上报道具,并新增 `mci-server`(Java)的 `/api/v1/channel-account/event` 接收端点,完成心跳、登录/在线状态、发送结果的双向 HTTP 集成。 **Architecture:** channel-service 所有状态变更与发送操作先写本地 `outbound_events`(SQLite),再由单线程后台 worker 按指数退避推送到 mci-server;mci-server 用 `event_id` 幂等并更新 `mci_channel_account` / `mci_send_record`。 **Tech Stack:** Python 3.11 + FastAPI + SQLite + httpx;Java 21 + Spring Boot 4.1.x + MyBatis Plus + MySQL 8。 --- ## 0. 文件结构总览 ### channel-service(Python) | 文件 | 操作 | 说明 | |------|------|------| | `channel-service/app/models/event.py` | 新建 | 事件枚举、数据类、payload schema | | `channel-service/app/repositories/event_store.py` | 新建 | SQLite `outbound_events` 表及 CRUD | | `channel-service/app/clients/mci_server_client.py` | 新建 | HTTP 客户端,POST 事件到 mci-server | | `channel-service/app/services/event_publisher.py` | 新建 | 单线程后台 worker、重试、死信 | | `channel-service/app/config.py` | 修改 | 新增事件存储/重试/清理配置 | | `channel-service/app/clients/pywechat_client.py` | 修改 | 增加登录/在线状态变更辅助方法 | | `channel-service/app/services/heartbeat_service.py` | 修改 | 心跳时写事件 | | `channel-service/app/services/login_service.py` | 修改 | 登录流程写事件 | | `channel-service/app/services/send_service.py` | 修改 | 发送改为异步受理并写 send_result 事件 | | `channel-service/app/api/wechat_personal.py` | 修改 | send 请求增加 `record_id`,新增 expire 路由 | | `channel-service/app/api/monitor.py` | 修改 | heartbeat 路由透传 `tenant_code` | | `channel-service/app/main.py` | 修改 | 启动/停止 EventPublisher worker | | `channel-service/tests/test_event_store.py` | 新建 | 事件存储测试 | | `channel-service/tests/test_event_publisher.py` | 新建 | worker 重试/死信测试 | | `channel-service/tests/test_send_async.py` | 新建 | 发送异步化测试 | ### mci-server(Java) | 文件 | 操作 | 说明 | |------|------|------| | `backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelEventRecord.java` | 新建 | 幂等去重表实体 | | `backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelEventRecordMapper.java` | 新建 | MyBatis Plus Mapper | | `backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountEventRequest.java` | 新建 | 事件接收 DTO | | `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountEventService.java` | 新建 | 事件处理逻辑 | | `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountEventController.java` | 新建 | 接收端点 | | `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java` | 修改 | dispatchRecord 时把 `record_id` 放入 extra | | `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapter.java` | 修改 | 把 `record_id` 传给 channel-service | | `backend/mci-server/src/main/resources/db/migration/V22__add_channel_event_record.sql` | 新建 | 幂等表 DDL | | `backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelAccountEventControllerTest.java` | 新建 | 控制器测试 | | `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapterTest.java` | 修改 | 验证 record_id 透传 | --- ## Phase 1: channel-service 事件基础设施 ### Task 1: 创建事件模型 `app/models/event.py` **Files:** - Create: `channel-service/app/models/event.py` - Test: `channel-service/tests/test_event_store.py`(后续步骤编写) - [ ] **Step 1: 编写事件枚举与数据类** ```python from __future__ import annotations import uuid from dataclasses import dataclass, field, asdict from datetime import datetime, timezone from enum import Enum from typing import Any, Optional class EventType(str, Enum): HEARTBEAT = "heartbeat" LOGIN_STATUS_CHANGED = "login_status_changed" ONLINE_STATUS_CHANGED = "online_status_changed" SEND_RESULT = "send_result" ERROR = "error" class EventStatus(str, Enum): PENDING = "pending" RETRYING = "retrying" DELIVERED = "delivered" DEAD_LETTER = "dead_letter" @dataclass class OutboundEvent: event_type: EventType account_id: str tenant_code: str data: dict[str, Any] event_id: str = field(default_factory=lambda: str(uuid.uuid4())) timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) def to_payload(self) -> dict[str, Any]: return { "event_id": self.event_id, "event_type": self.event_type.value, "account_id": self.account_id, "tenant_code": self.tenant_code, "timestamp": self.timestamp.isoformat(), "data": self.data, } ``` - [ ] **Step 2: Commit** ```bash cd /Users/marsal/Projects/sino-project/sino-mci/channel-service git add app/models/event.py git commit -m "feat(event): add outbound event models" ``` --- ### Task 2: 创建 SQLite 事件存储 `app/repositories/event_store.py` **Files:** - Create: `channel-service/app/repositories/event_store.py` - Test: `channel-service/tests/test_event_store.py` - [ ] **Step 1: 编写测试 — 验证建表与保存事件** ```python import os import tempfile import pytest from app.models.event import EventType, OutboundEvent from app.repositories.event_store import EventStore @pytest.fixture def store(): fd, path = tempfile.mkstemp(suffix=".db") os.close(fd) store = EventStore(path) yield store os.unlink(path) def test_save_and_fetch_pending(store): event = OutboundEvent( event_type=EventType.HEARTBEAT, account_id="bot-001", tenant_code="rescue", data={"online_status": "online"}, ) store.save(event) pending = store.fetch_pending(limit=10) assert len(pending) == 1 assert pending[0].event_id == event.event_id def test_mark_delivered(store): event = OutboundEvent( event_type=EventType.HEARTBEAT, account_id="bot-001", tenant_code="rescue", data={}, ) store.save(event) store.mark_delivered(event.event_id) pending = store.fetch_pending(limit=10) assert len(pending) == 0 ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /Users/marsal/Projects/sino-project/sino-mci/channel-service pytest tests/test_event_store.py -v ``` Expected: `ModuleNotFoundError` 或 `ImportError`。 - [ ] **Step 3: 实现 EventStore** ```python from __future__ import annotations import json import sqlite3 import threading from datetime import datetime, timedelta, timezone from typing import Optional from app.models.event import EventStatus, EventType, OutboundEvent class EventStore: def __init__(self, db_path: str): self.db_path = db_path self._local = threading.local() self._init_db() def _now(self) -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) def _connection(self) -> sqlite3.Connection: if not hasattr(self._local, "conn") or self._local.conn is None: self._local.conn = sqlite3.connect(self.db_path, check_same_thread=False) self._local.conn.row_factory = sqlite3.Row return self._local.conn def _init_db(self) -> None: with sqlite3.connect(self.db_path) as conn: conn.execute( """ 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 ) """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_status_next_retry ON outbound_events(status, next_retry_at)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_created_at ON outbound_events(created_at)" ) def save(self, event: OutboundEvent) -> None: now = self._now() with self._connection() as conn: conn.execute( """ INSERT INTO outbound_events (id, event_type, payload, status, attempt_count, next_retry_at, created_at, updated_at, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.event_id, event.event_type.value, json.dumps(event.to_payload(), default=str), EventStatus.PENDING.value, 0, now, now, now, None, ), ) def fetch_pending(self, limit: int = 100) -> list[OutboundEvent]: now = self._now() cursor = self._connection().execute( """ SELECT id, event_type, payload, status, attempt_count FROM outbound_events WHERE status IN ('pending', 'retrying') AND next_retry_at <= ? ORDER BY next_retry_at ASC LIMIT ? """, (now, limit), ) return [self._row_to_event(row) for row in cursor.fetchall()] def _row_to_event(self, row: sqlite3.Row) -> OutboundEvent: payload = json.loads(row["payload"]) return OutboundEvent( event_id=payload["event_id"], event_type=EventType(payload["event_type"]), account_id=payload["account_id"], tenant_code=payload["tenant_code"], data=payload["data"], status=EventStatus(row["status"]), attempt_count=row["attempt_count"], ) def mark_delivered(self, event_id: str) -> None: now = self._now() with self._connection() as conn: conn.execute( "UPDATE outbound_events SET status = 'delivered', updated_at = ? WHERE id = ?", (now, event_id), ) def mark_retry(self, event_id: str, status: EventStatus, attempt_count: int, next_retry_at: datetime, last_error: Optional[str]) -> None: now = self._now() with self._connection() as conn: conn.execute( """ UPDATE outbound_events SET status = ?, attempt_count = ?, next_retry_at = ?, updated_at = ?, last_error = ? WHERE id = ? """, (status.value, attempt_count, next_retry_at, now, last_error, event_id), ) def cleanup(self, retention_days: int) -> int: threshold = self._now() - timedelta(days=retention_days) with self._connection() as conn: cursor = conn.execute( """ DELETE FROM outbound_events WHERE status IN ('delivered', 'dead_letter') AND created_at < ? """, (threshold,), ) return cursor.rowcount ``` - [ ] **Step 4: 运行测试,确认通过** ```bash pytest tests/test_event_store.py -v ``` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add app/repositories/event_store.py tests/test_event_store.py git commit -m "feat(event): add SQLite outbound event store" ``` --- ### Task 3: 创建 mci-server HTTP 客户端 `app/clients/mci_server_client.py` **Files:** - Create: `channel-service/app/clients/mci_server_client.py` - Test: `channel-service/tests/test_event_publisher.py`(后续步骤) - [ ] **Step 1: 实现客户端** ```python from __future__ import annotations import httpx from app.models.event import OutboundEvent class MciServerClient: def __init__(self, base_url: str, timeout: float = 10.0): self.base_url = base_url.rstrip("/") self.timeout = timeout async def post_event(self, event: OutboundEvent) -> None: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.base_url}/api/v1/channel-account/event", json=event.to_payload(), ) response.raise_for_status() ``` - [ ] **Step 2: Commit** ```bash git add app/clients/mci_server_client.py git commit -m "feat(client): add mci-server event poster" ``` --- ### Task 4: 创建单线程事件发布 worker `app/services/event_publisher.py` **Files:** - Create: `channel-service/app/services/event_publisher.py` - Test: `channel-service/tests/test_event_publisher.py` - [ ] **Step 1: 安装测试依赖 `respx`** 在 `channel-service/requirements.txt` 中追加: ```text respx==0.22.0 ``` 然后安装: ```bash cd /Users/marsal/Projects/sino-project/sino-mci/channel-service pip install -r requirements.txt ``` - [ ] **Step 2: 编写测试 — 验证成功推送与重试** ```python import asyncio from datetime import datetime, timezone import httpx import pytest import respx from httpx import Response from app.clients.mci_server_client import MciServerClient from app.models.event import EventType, OutboundEvent from app.repositories.event_store import EventStore from app.services.event_publisher import EventPublisher @pytest.fixture def publisher(tmp_path): db_path = tmp_path / "events.db" store = EventStore(str(db_path)) client = MciServerClient("http://mock-mci-server/msg-platform") pub = EventPublisher(store, client, max_retry=2, base_delay_seconds=1, interval_seconds=0.1) yield pub @respx.mock def test_publish_delivers_event(publisher): route = respx.post("http://mock-mci-server/msg-platform/api/v1/channel-account/event").mock(return_value=Response(200)) event = OutboundEvent(EventType.HEARTBEAT, "bot-001", "rescue", {"online_status": "online"}) publisher.store.save(event) async def run(): await publisher._tick() asyncio.run(run()) assert route.called pending = publisher.store.fetch_pending(limit=10) assert len(pending) == 0 @respx.mock def test_publish_retries_then_dead_letter(publisher): route = respx.post("http://mock-mci-server/msg-platform/api/v1/channel-account/event").mock(return_value=Response(500)) event = OutboundEvent(EventType.HEARTBEAT, "bot-001", "rescue", {}) publisher.store.save(event) async def run(): await publisher._tick() asyncio.run(run()) assert route.call_count == 1 updated = publisher.store.fetch_pending(limit=10) assert len(updated) == 1 assert updated[0].status == EventStatus.RETRYING ``` > 注意:`EventStore._row_to_event` 目前未保留 status,需要给 OutboundEvent 增加 status 字段或在 store 返回元组。为保持简单,可以在 `fetch_pending` 返回 `(OutboundEvent, status, attempt_count)` 元组,或在 OutboundEvent 上增加 `status` 与 `attempt_count` 字段。下面选择给 OutboundEvent 增加字段。 - [ ] **Step 2: 运行测试,确认失败** ```bash pytest tests/test_event_publisher.py -v ``` Expected: ImportError / AttributeError。 - [ ] **Step 3: 修改 `app/models/event.py`,给 OutboundEvent 增加状态字段** ```python @dataclass class OutboundEvent: event_type: EventType account_id: str tenant_code: str data: dict[str, Any] event_id: str = field(default_factory=lambda: str(uuid.uuid4())) timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) status: EventStatus = EventStatus.PENDING attempt_count: int = 0 ``` - [ ] **Step 4: 修改 `app/repositories/event_store.py` 的 `_row_to_event`,回填 status 与 attempt_count** ```python def _row_to_event(self, row: sqlite3.Row) -> OutboundEvent: payload = json.loads(row["payload"]) return OutboundEvent( event_id=payload["event_id"], event_type=EventType(payload["event_type"]), account_id=payload["account_id"], tenant_code=payload["tenant_code"], data=payload["data"], status=EventStatus(row["status"]), attempt_count=row["attempt_count"], ) ``` - [ ] **Step 5: 实现 EventPublisher** ```python from __future__ import annotations import asyncio import math from datetime import datetime, timedelta, timezone from typing import Optional from app.clients.mci_server_client import MciServerClient from app.models.event import EventStatus, OutboundEvent from app.repositories.event_store import EventStore class EventPublisher: def __init__( self, store: EventStore, client: MciServerClient, max_retry: int = 5, base_delay_seconds: int = 5, interval_seconds: float = 5.0, batch_size: int = 100, ): self.store = store self.client = client self.max_retry = min(max_retry, 10) self.base_delay_seconds = base_delay_seconds self.interval_seconds = interval_seconds self.batch_size = batch_size self._task: Optional[asyncio.Task] = None self._stop_event = asyncio.Event() def start(self) -> None: self._task = asyncio.create_task(self._loop()) async def stop(self) -> None: self._stop_event.set() if self._task: try: await asyncio.wait_for(self._task, timeout=5.0) except asyncio.TimeoutError: self._task.cancel() async def _loop(self) -> None: while not self._stop_event.is_set(): try: await self._tick() except Exception: pass try: await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds) except asyncio.TimeoutError: pass async def _tick(self) -> None: events = self.store.fetch_pending(limit=self.batch_size) for event in events: try: await self.client.post_event(event) self.store.mark_delivered(event.event_id) except Exception as e: next_attempt = event.attempt_count + 1 delay = self.base_delay_seconds * (2 ** (next_attempt - 1)) next_retry_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=delay) status = EventStatus.DEAD_LETTER if next_attempt >= self.max_retry else EventStatus.RETRYING self.store.mark_retry(event.event_id, status, next_attempt, next_retry_at, str(e)[:500]) async def cleanup_old_events(self, retention_days: int) -> int: return self.store.cleanup(retention_days) ``` - [ ] **Step 6: 运行测试,确认通过** ```bash pytest tests/test_event_publisher.py -v ``` Expected: PASS。 - [ ] **Step 7: Commit** ```bash git add app/models/event.py app/repositories/event_store.py app/services/event_publisher.py tests/test_event_publisher.py git commit -m "feat(event): add single-thread event publisher with retry" ``` --- ### Task 5: 扩展配置 `app/config.py` **Files:** - Modify: `channel-service/app/config.py` - [ ] **Step 1: 添加配置项** ```python class Settings(BaseSettings): app_name: str = "sino-mci-channel-service" debug: bool = os.getenv("DEBUG", "false").lower() == "true" host: str = "0.0.0.0" port: int = int(os.getenv("PORT", "8000")) mci_server_url: str = os.getenv("MCI_SERVER_URL", "http://localhost:8080/msg-platform") heartbeat_interval_seconds: int = int(os.getenv("HEARTBEAT_INTERVAL_SECONDS", "30")) pywechat_mock_mode: bool = os.getenv("PYWECHAT_MOCK_MODE", "true").lower() == "true" event_store_path: str = os.getenv("EVENT_STORE_PATH", "./data/events.db") max_retry: int = int(os.getenv("MAX_RETRY", "5")) base_retry_delay_seconds: int = int(os.getenv("BASE_RETRY_DELAY_SECONDS", "5")) event_retention_days: int = int(os.getenv("EVENT_RETENTION_DAYS", "10")) event_publish_interval_seconds: float = float(os.getenv("EVENT_PUBLISH_INTERVAL_SECONDS", "5")) ``` - [ ] **Step 2: Commit** ```bash git add app/config.py git commit -m "feat(config): add event publisher settings" ``` --- ## Phase 2: channel-service 业务服务接入事件 ### Task 6: 改造心跳服务,发送 heartbeat 事件 **Files:** - Modify: `channel-service/app/services/heartbeat_service.py` - Modify: `channel-service/app/api/monitor.py` - Test: `channel-service/tests/test_api.py` - [ ] **Step 1: 修改 HeartbeatRequest,增加 tenant_code** 在 `app/api/monitor.py` 中: ```python class HeartbeatRequest(BaseModel): account_id: str = Field(..., description="渠道账号唯一标识") account_name: str | None = Field(None) tenant_code: str = Field("default", description="业务系统租户编码") risk_level: int = Field(0, ge=0, le=100) sent_count_today: int = Field(0, ge=0) ``` - [ ] **Step 2: 修改 HeartbeatService,注入 EventStore 并写事件** ```python from app.models.event import EventType, OutboundEvent from app.repositories.event_store import EventStore class HeartbeatService: def __init__(self, registry: ClientRegistry, store: EventStore): self.registry = registry self.store = store def heartbeat(self, account_id: str, payload: dict) -> dict: client = self.registry.get_or_create(account_id, payload.get("account_name")) client.heartbeat() if "risk_level" in payload: client.state.risk_level = payload["risk_level"] if "sent_count_today" in payload: client.state.sent_count_today = payload["sent_count_today"] event = OutboundEvent( event_type=EventType.HEARTBEAT, account_id=account_id, tenant_code=payload.get("tenant_code", "default"), data={ "login_status": client.state.login_status.value, "online_status": client.state.online_status.value, "risk_level": client.state.risk_level, "sent_count_today": client.state.sent_count_today, }, ) self.store.save(event) return {"success": True, "account_id": account_id} ``` - [ ] **Step 3: 修改 monitor.py heartbeat 路由,传入 tenant_code** ```python @router.post("/v1/heartbeat") def heartbeat(request: HeartbeatRequest): return heartbeat_service.heartbeat(request.account_id, request.model_dump(exclude_none=True)) ``` - [ ] **Step 4: 修改 `app/services/__init__.py` 或 `app/main.py` 中初始化方式,注入 EventStore 单例** 先创建全局 event_store: ```python # app/repositories/event_store.py 底部追加 event_store = EventStore(settings.event_store_path) ``` 然后修改 `app/services/heartbeat_service.py` 底部: ```python from app.repositories.event_store import event_store heartbeat_service = HeartbeatService(registry, event_store) ``` - [ ] **Step 5: 更新测试 `tests/test_api.py` 中的 heartbeat 测试,验证事件写入** ```python def test_heartbeat_creates_event(): from app.repositories.event_store import event_store event_store.cleanup(0) client.post("/v1/heartbeat", json={ "account_id": "bot-heartbeat", "tenant_code": "rescue", "risk_level": 10, "sent_count_today": 5, }) pending = event_store.fetch_pending(limit=10) assert any(e.event_type == EventType.HEARTBEAT and e.tenant_code == "rescue" for e in pending) ``` - [ ] **Step 6: 运行 channel-service 全部测试** ```bash pytest -v ``` Expected: PASS(可能需要调整旧测试)。 - [ ] **Step 7: Commit** ```bash git add app/services/heartbeat_service.py app/api/monitor.py app/repositories/event_store.py tests/test_api.py git commit -m "feat(heartbeat): emit heartbeat event to SQLite" ``` --- ### Task 7: 改造登录服务,发送 login/online 状态事件 **Files:** - Modify: `channel-service/app/services/login_service.py` - Modify: `channel-service/app/clients/pywechat_client.py` - Test: `channel-service/tests/test_api.py` - [ ] **Step 1: 给 PyWeChatClient 增加状态变更辅助方法** 在 `app/clients/pywechat_client.py` 中: ```python def set_login_status(self, status: LoginStatus, error_message: Optional[str] = None) -> None: old = self.state.login_status self.state.login_status = status if status == LoginStatus.SUCCESS: self.state.online_status = OnlineStatus.ONLINE self.state.last_heartbeat_at = time.time() elif status in (LoginStatus.EXPIRED, LoginStatus.FAILED): self.state.online_status = OnlineStatus.OFFLINE if error_message: self.state.error_message = error_message def set_online_status(self, status: OnlineStatus) -> None: self.state.online_status = status ``` - [ ] **Step 2: 修改 LoginService,注入 EventStore 并写事件** ```python from app.models.event import EventType, OutboundEvent from app.repositories.event_store import EventStore class LoginService: def __init__(self, registry: ClientRegistry, store: EventStore): self.registry = registry self.store = store def start_login(self, account_id: str, account_name: Optional[str] = None, tenant_code: str = "default") -> dict: client = self.registry.get_or_create(account_id, account_name) login_id = client.start_login() self._emit_login_changed(account_id, tenant_code, LoginStatus.PENDING, client.state.login_status, client.state.qr_code_url) return { "login_id": login_id, "qr_code_url": client.state.qr_code_url, "status": client.state.login_status.value, } def confirm_login(self, account_id: str, tenant_code: str = "default") -> dict: client = self.registry.get(account_id) if client is None: return {"success": False, "error": "account not found"} previous_login = client.state.login_status previous_online = client.state.online_status client.set_login_status(LoginStatus.SUCCESS) self._emit_login_changed(account_id, tenant_code, previous_login, client.state.login_status) self._emit_online_changed(account_id, tenant_code, previous_online, client.state.online_status) return {"success": True, "status": client.state.login_status.value} def expire_login(self, account_id: str, tenant_code: str = "default") -> dict: client = self.registry.get(account_id) if client is None: return {"success": False, "error": "account not found"} previous = client.state.login_status client.set_login_status(LoginStatus.EXPIRED) self._emit_login_changed(account_id, tenant_code, previous, client.state.login_status) return {"success": True, "status": client.state.login_status.value} def _emit_login_changed(self, account_id: str, tenant_code: str, previous: LoginStatus, current: LoginStatus, qr_code_url: Optional[str] = None) -> None: event = OutboundEvent( event_type=EventType.LOGIN_STATUS_CHANGED, account_id=account_id, tenant_code=tenant_code, data={ "previous": previous.value, "current": current.value, "qr_code_url": qr_code_url, }, ) self.store.save(event) def _emit_online_changed(self, account_id: str, tenant_code: str, previous: OnlineStatus, current: OnlineStatus) -> None: event = OutboundEvent( event_type=EventType.ONLINE_STATUS_CHANGED, account_id=account_id, tenant_code=tenant_code, data={ "previous": previous.value, "current": current.value, }, ) self.store.save(event) ``` - [ ] **Step 3: 修改 API 路由传入 tenant_code** 在 `app/api/wechat_personal.py` 中: ```python class StartLoginRequest(BaseModel): account_id: str = Field(..., description="渠道账号唯一标识") account_name: Optional[str] = Field(None, description="账号显示名称") tenant_code: str = Field("default", description="业务系统租户编码") @router.post("/login/start") def start_login(request: StartLoginRequest): return login_service.start_login(request.account_id, request.account_name, request.tenant_code) class LoginConfirmRequest(BaseModel): tenant_code: str = Field("default", description="业务系统租户编码") @router.post("/login/{account_id}/confirm") def confirm_login(account_id: str, request: LoginConfirmRequest): return login_service.confirm_login(account_id, request.tenant_code) ``` - [ ] **Step 4: 更新 login_service 单例注入 EventStore** ```python from app.repositories.event_store import event_store login_service = LoginService(registry, event_store) ``` - [ ] **Step 5: 运行测试并 Commit** ```bash pytest -v git add app/clients/pywechat_client.py app/services/login_service.py app/api/wechat_personal.py tests/test_api.py git commit -m "feat(login): emit login and online status events" ``` --- ### Task 8: 改造发送服务,改为异步受理并发送 send_result 事件 **Files:** - Modify: `channel-service/app/services/send_service.py` - Modify: `channel-service/app/api/wechat_personal.py` - Test: `channel-service/tests/test_send_async.py` - [ ] **Step 1: 修改 SendTextRequest / SendImageRequest 增加 record_id 与 tenant_code** ```python class SendTextRequest(BaseModel): account_id: str conversation_id: str = Field(..., description="目标会话/群 ID") text: str = Field(..., min_length=1, description="消息内容") record_id: str = Field(..., description="mci-server 发送记录 ID") tenant_code: str = Field("default", description="业务系统租户编码") class SendImageRequest(BaseModel): account_id: str conversation_id: str image_url: str record_id: str = Field(..., description="mci-server 发送记录 ID") tenant_code: str = Field("default", description="业务系统租户编码") ``` - [ ] **Step 2: 修改 SendService 为异步受理并写 send_result 事件** ```python import uuid from app.models.event import EventType, OutboundEvent from app.repositories.event_store import EventStore class SendService: def __init__(self, registry: ClientRegistry, store: EventStore): self.registry = registry self.store = store def send_text(self, account_id: str, conversation_id: str, text: str, record_id: str, tenant_code: str = "default") -> dict: client = self.registry.get(account_id) if client is None: return self._fail(account_id, record_id, tenant_code, conversation_id, f"account {account_id} not registered") if client.state.online_status != OnlineStatus.ONLINE: return self._fail(account_id, record_id, tenant_code, conversation_id, "account not online") if client.state.risk_level >= 80: return self._fail(account_id, record_id, tenant_code, conversation_id, "account risk level too high", error_code="RISK_CONTROL_BLOCKED") message_id = f"mock-msg-{uuid.uuid4().hex[:16]}" result = client.send_text(conversation_id, text) self._emit_send_result(account_id, record_id, tenant_code, conversation_id, True, message_id, result) return {"accepted": True, "message_id": message_id} def send_image(self, account_id: str, conversation_id: str, image_url: str, record_id: str, tenant_code: str = "default") -> dict: client = self.registry.get(account_id) if client is None: return self._fail(account_id, record_id, tenant_code, conversation_id, f"account {account_id} not registered") if client.state.online_status != OnlineStatus.ONLINE: return self._fail(account_id, record_id, tenant_code, conversation_id, "account not online") message_id = f"mock-img-{image_url.split('/')[-1]}" self._emit_send_result(account_id, record_id, tenant_code, conversation_id, True, message_id, {"image_url": image_url}) return {"accepted": True, "message_id": message_id} def _fail(self, account_id: str, record_id: str, tenant_code: str, conversation_id: str, error: str, error_code: str = "SEND_FAILED") -> dict: self._emit_send_result(account_id, record_id, tenant_code, conversation_id, False, None, {"error": error, "error_code": error_code}) return {"accepted": False, "error": error} def _emit_send_result(self, account_id: str, record_id: str, tenant_code: str, conversation_id: str, success: bool, message_id: Optional[str], channel_result: dict) -> None: event = OutboundEvent( event_type=EventType.SEND_RESULT, account_id=account_id, tenant_code=tenant_code, data={ "record_id": record_id, "conversation_id": conversation_id, "success": success, "message_id": message_id, "channel_result": channel_result, "error": channel_result.get("error"), }, ) self.store.save(event) ``` - [ ] **Step 3: 修改 API 路由** ```python @router.post("/send/text") def send_text(request: SendTextRequest): return send_service.send_text( request.account_id, request.conversation_id, request.text, request.record_id, request.tenant_code, ) @router.post("/send/image") def send_image(request: SendImageRequest): return send_service.send_image( request.account_id, request.conversation_id, request.image_url, request.record_id, request.tenant_code, ) ``` - [ ] **Step 4: 更新 send_service 单例注入 EventStore** ```python from app.repositories.event_store import event_store send_service = SendService(registry, event_store) ``` - [ ] **Step 5: 更新旧测试** `tests/test_api.py` 中 `test_send_after_login` 和 `test_send_without_login_fails` 需要增加 `record_id`: ```python {"account_id": "bot-002", "conversation_id": "room-001", "text": "hello", "record_id": "rec-001"} ``` 并把 `assert r.json()["success"] is True` 改为 `assert r.json()["accepted"] is True`。 - [ ] **Step 6: 新增 `tests/test_send_async.py`** ```python from fastapi.testclient import TestClient from app.main import app from app.models.event import EventType from app.repositories.event_store import event_store client = TestClient(app) def test_send_text_creates_send_result_event(): event_store.cleanup(0) client.post("/v1/wechat-personal/login/start", json={"account_id": "send-bot", "tenant_code": "rescue"}) client.post("/v1/wechat-personal/login/send-bot/confirm", json={"tenant_code": "rescue"}) r = client.post("/v1/wechat-personal/send/text", json={ "account_id": "send-bot", "conversation_id": "room-001", "text": "hello", "record_id": "rec-123", "tenant_code": "rescue", }) assert r.status_code == 200 assert r.json()["accepted"] is True pending = event_store.fetch_pending(limit=10) assert any(e.event_type == EventType.SEND_RESULT and e.data.get("record_id") == "rec-123" for e in pending) ``` - [ ] **Step 7: 运行测试并 Commit** ```bash pytest -v git add app/services/send_service.py app/api/wechat_personal.py tests/test_api.py tests/test_send_async.py git commit -m "feat(send): async acceptance and send_result event" ``` --- ### Task 9: 在 `app/main.py` 启动/停止 EventPublisher worker **Files:** - Modify: `channel-service/app/main.py` - Modify: `channel-service/app/clients/mci_server_client.py`(可选,创建单例) - [ ] **Step 1: 在 `app/repositories/event_store.py` 创建全局 event_store 单例(已完成)** - [ ] **Step 2: 修改 `app/main.py`** ```python from contextlib import asynccontextmanager from fastapi import FastAPI from app.api import health, monitor, wechat_personal from app.clients.mci_server_client import MciServerClient from app.config import settings from app.repositories.event_store import event_store from app.services.event_publisher import EventPublisher @asynccontextmanager async def lifespan(app: FastAPI): client = MciServerClient(settings.mci_server_url) publisher = EventPublisher( event_store, client, max_retry=settings.max_retry, base_delay_seconds=settings.base_retry_delay_seconds, interval_seconds=settings.event_publish_interval_seconds, ) publisher.start() yield await publisher.stop() app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan) app.include_router(health.router) app.include_router(wechat_personal.router) app.include_router(monitor.router) if __name__ == "__main__": import uvicorn uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=settings.debug) ``` - [ ] **Step 3: 运行应用并验证启动无报错** ```bash python -c "from app.main import app; print('ok')" ``` Expected: 输出 `ok`。 - [ ] **Step 4: Commit** ```bash git add app/main.py git commit -m "feat(main): start and stop event publisher worker" ``` --- ## Phase 3: channel-service 补充测试与清理任务 ### Task 10: 添加事件清理测试与每日清理触发 **Files:** - Modify: `channel-service/app/services/event_publisher.py` - Test: `channel-service/tests/test_event_store.py` - [ ] **Step 1: 给 EventPublisher 增加定时清理** 在 `_loop` 中每 24 小时调用一次 `cleanup_old_events`: ```python async def _loop(self) -> None: cleanup_counter = 0 while not self._stop_event.is_set(): try: await self._tick() except Exception: pass cleanup_counter += 1 if cleanup_counter >= 17280: # 5s * 17280 = 24h try: await self.cleanup_old_events(10) except Exception: pass cleanup_counter = 0 try: await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds) except asyncio.TimeoutError: pass ``` - [ ] **Step 2: 补充清理测试** ```python from datetime import datetime, timedelta, timezone from app.models.event import EventType, OutboundEvent def test_cleanup_old_delivered_events(store): old_event = OutboundEvent(EventType.HEARTBEAT, "bot-001", "rescue", {}) store.save(old_event) store.mark_delivered(old_event.event_id) # 修改 created_at 为 11 天前 with store._connection() as conn: conn.execute( "UPDATE outbound_events SET created_at = ? WHERE id = ?", (datetime.now(timezone.utc) - timedelta(days=11), old_event.event_id), ) deleted = store.cleanup(10) assert deleted == 1 ``` - [ ] **Step 3: 运行测试并 Commit** ```bash pytest -v git add app/services/event_publisher.py tests/test_event_store.py git commit -m "feat(event): add daily cleanup for delivered/dead-letter events" ``` --- ## Phase 4: mci-server 事件接收端 ### Task 11: 新增幂等去重表 `mci_channel_event_record` **Files:** - Create: `backend/mci-server/src/main/resources/db/migration/V22__add_channel_event_record.sql` - Create: `backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelEventRecord.java` - Create: `backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelEventRecordMapper.java` - [ ] **Step 1: 编写 Flyway 脚本** ```sql CREATE TABLE IF NOT EXISTS mci_channel_event_record ( event_id VARCHAR(64) PRIMARY KEY, account_id VARCHAR(64) NOT NULL, event_type VARCHAR(32) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_channel_event_account_time ON mci_channel_event_record(account_id, create_time); ``` - [ ] **Step 2: 创建实体类** ```java package com.sino.mci.channel.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("mci_channel_event_record") public class ChannelEventRecord { @TableId(type = IdType.INPUT) private String eventId; private String accountId; private String eventType; private LocalDateTime createTime; } ``` - [ ] **Step 3: 创建 Mapper** ```java package com.sino.mci.channel.repository; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.sino.mci.channel.entity.ChannelEventRecord; import org.apache.ibatis.annotations.Mapper; @Mapper public interface ChannelEventRecordMapper extends BaseMapper { } ``` - [ ] **Step 4: Commit** ```bash cd /Users/marsal/Projects/sino-project/sino-mci git add backend/mci-server/src/main/resources/db/migration/V22__add_channel_event_record.sql \ backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelEventRecord.java \ backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelEventRecordMapper.java git commit -m "feat(db): add channel event record idempotency table" ``` --- ### Task 12: 创建事件处理服务 `ChannelAccountEventService` **Files:** - Create: `backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountEventRequest.java` - Create: `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountEventService.java` - [ ] **Step 1: 创建请求 DTO** ```java package com.sino.mci.channel.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import java.util.Map; @Data public class ChannelAccountEventRequest { @NotBlank private String eventId; @NotBlank private String eventType; @NotBlank private String accountId; @NotBlank private String tenantCode; private String timestamp; @NotNull private Map data; } ``` - [ ] **Step 2: 创建服务类** ```java package com.sino.mci.channel.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.sino.mci.channel.dto.ChannelAccountEventRequest; import com.sino.mci.channel.entity.ChannelAccount; import com.sino.mci.channel.entity.ChannelEventRecord; import com.sino.mci.channel.repository.ChannelAccountMapper; import com.sino.mci.channel.repository.ChannelEventRecordMapper; import com.sino.mci.exception.BusinessException; import com.sino.mci.send.entity.SendRecord; import com.sino.mci.send.repository.SendRecordMapper; import com.sino.mci.send.service.SendCallbackService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.Map; @Slf4j @Service @RequiredArgsConstructor public class ChannelAccountEventService { private static final int LOGIN_STATUS_PENDING = 0; private static final int LOGIN_STATUS_SUCCESS = 1; private static final int LOGIN_STATUS_EXPIRED = 2; private static final int LOGIN_STATUS_FAILED = 3; private static final int ONLINE_STATUS_OFFLINE = 0; private static final int ONLINE_STATUS_ONLINE = 1; private static final int ONLINE_STATUS_ERROR = 2; private static final int SEND_STATUS_SUCCESS = 2; private static final int SEND_STATUS_FAILED = 3; private final ChannelAccountMapper accountMapper; private final ChannelEventRecordMapper eventRecordMapper; private final SendRecordMapper sendRecordMapper; private final SendCallbackService sendCallbackService; @Transactional public void handleEvent(ChannelAccountEventRequest request) { if (isDuplicate(request.getEventId())) { log.debug("重复事件已忽略: eventId={}", request.getEventId()); return; } ChannelAccount account = findAccount(request.getTenantCode(), request.getAccountId()); switch (request.getEventType()) { case "heartbeat": handleHeartbeat(account, request.getData()); break; case "login_status_changed": handleLoginStatusChanged(account, request.getData()); break; case "online_status_changed": handleOnlineStatusChanged(account, request.getData()); break; case "send_result": handleSendResult(request.getData()); break; case "error": log.warn("channel-service 上报错误: accountId={}, data={}", request.getAccountId(), request.getData()); break; default: log.warn("未知事件类型: {}", request.getEventType()); } saveEventRecord(request); } private boolean isDuplicate(String eventId) { return eventRecordMapper.selectById(eventId) != null; } private void saveEventRecord(ChannelAccountEventRequest request) { ChannelEventRecord record = new ChannelEventRecord(); record.setEventId(request.getEventId()); record.setAccountId(request.getAccountId()); record.setEventType(request.getEventType()); record.setCreateTime(LocalDateTime.now()); eventRecordMapper.insert(record); } private ChannelAccount findAccount(String tenantCode, String accountId) { ChannelAccount account = accountMapper.selectOne( new LambdaQueryWrapper() .eq(ChannelAccount::getTenantCode, tenantCode) .eq(ChannelAccount::getAccountId, accountId)); if (account == null) { throw new BusinessException("渠道账号不存在: " + accountId); } return account; } private void handleHeartbeat(ChannelAccount account, Map data) { account.setLoginStatus(parseLoginStatus(getString(data, "login_status"))); account.setOnlineStatus(parseOnlineStatus(getString(data, "online_status"))); account.setLastHeartbeat(LocalDateTime.now()); Integer riskLevel = getInteger(data, "risk_level"); if (riskLevel != null) { account.setRiskLevel(riskLevel); } accountMapper.updateById(account); } private void handleLoginStatusChanged(ChannelAccount account, Map data) { String current = getString(data, "current"); account.setLoginStatus(parseLoginStatus(current)); if ("success".equalsIgnoreCase(current)) { account.setOnlineStatus(ONLINE_STATUS_ONLINE); } accountMapper.updateById(account); } private void handleOnlineStatusChanged(ChannelAccount account, Map data) { account.setOnlineStatus(parseOnlineStatus(getString(data, "current"))); accountMapper.updateById(account); } private void handleSendResult(Map data) { Long recordId = getLong(data, "record_id"); if (recordId == null) { log.warn("send_result 事件缺少 record_id"); return; } SendRecord record = sendRecordMapper.selectById(recordId); if (record == null) { log.warn("发送记录不存在: recordId={}", recordId); return; } Boolean success = getBoolean(data, "success"); record.setSendStatus(Boolean.TRUE.equals(success) ? SEND_STATUS_SUCCESS : SEND_STATUS_FAILED); record.setFinishTime(LocalDateTime.now()); record.setChannelResult(toJson(data.get("channel_result"))); sendRecordMapper.updateById(record); sendCallbackService.sendCallback(record); } private int parseLoginStatus(String status) { if ("success".equalsIgnoreCase(status)) { return LOGIN_STATUS_SUCCESS; } if ("expired".equalsIgnoreCase(status)) { return LOGIN_STATUS_EXPIRED; } if ("failed".equalsIgnoreCase(status)) { return LOGIN_STATUS_FAILED; } return LOGIN_STATUS_PENDING; } private int parseOnlineStatus(String status) { if ("online".equalsIgnoreCase(status)) { return ONLINE_STATUS_ONLINE; } if ("error".equalsIgnoreCase(status)) { return ONLINE_STATUS_ERROR; } return ONLINE_STATUS_OFFLINE; } private String getString(Map map, String key) { Object value = map.get(key); return value == null ? null : value.toString(); } private Integer getInteger(Map map, String key) { Object value = map.get(key); if (value instanceof Number n) { return n.intValue(); } return null; } private Long getLong(Map map, String key) { Object value = map.get(key); if (value instanceof Number n) { return n.longValue(); } if (value instanceof String s) { try { return Long.parseLong(s); } catch (NumberFormatException e) { return null; } } return null; } private Boolean getBoolean(Map map, String key) { Object value = map.get(key); if (value instanceof Boolean b) { return b; } return null; } private String toJson(Object value) { if (value == null) { return null; } try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(value); } catch (Exception e) { return value.toString(); } } } ``` - [ ] **Step 3: Commit** ```bash git add backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountEventRequest.java \ backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountEventService.java git commit -m "feat(channel): add event handling service" ``` --- ### Task 13: 创建事件接收 Controller **Files:** - Create: `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountEventController.java` - Test: `backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelAccountEventControllerTest.java` - [ ] **Step 1: 创建 Controller** ```java package com.sino.mci.channel.controller; import com.sino.mci.channel.dto.ChannelAccountEventRequest; import com.sino.mci.channel.service.ChannelAccountEventService; import com.sino.mci.shared.web.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/channel-account") @RequiredArgsConstructor public class ChannelAccountEventController { private final ChannelAccountEventService eventService; @PostMapping("/event") public ApiResponse receiveEvent(@Valid @RequestBody ChannelAccountEventRequest request) { eventService.handleEvent(request); return ApiResponse.ok(null); } } ``` - [ ] **Step 2: 编写 Controller 测试** ```java package com.sino.mci.channel.controller; import com.fasterxml.jackson.databind.ObjectMapper; import com.sino.mci.channel.dto.ChannelAccountEventRequest; import com.sino.mci.channel.entity.ChannelAccount; import com.sino.mci.channel.repository.ChannelAccountMapper; import com.sino.mci.channel.repository.ChannelEventRecordMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MockMvc; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @Sql(scripts = "/sql/test-channel-account.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) class ChannelAccountEventControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Autowired private ChannelAccountMapper accountMapper; @Autowired private ChannelEventRecordMapper eventRecordMapper; @Test void shouldUpdateAccountOnHeartbeat() throws Exception { ChannelAccountEventRequest request = new ChannelAccountEventRequest(); request.setEventId("evt-001"); request.setEventType("heartbeat"); request.setAccountId("wx-bot-01"); request.setTenantCode("rescue"); request.setData(Map.of( "login_status", "success", "online_status", "online", "risk_level", 10)); mockMvc.perform(post("/api/v1/channel-account/event") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); ChannelAccount account = accountMapper.selectById(1L); assertThat(account.getOnlineStatus()).isEqualTo(1); assertThat(account.getRiskLevel()).isEqualTo(10); assertThat(eventRecordMapper.selectById("evt-001")).isNotNull(); } @Test void shouldIgnoreDuplicateEvent() throws Exception { ChannelAccountEventRequest request = new ChannelAccountEventRequest(); request.setEventId("evt-dup-001"); request.setEventType("heartbeat"); request.setAccountId("wx-bot-01"); request.setTenantCode("rescue"); request.setData(Map.of("online_status", "online")); mockMvc.perform(post("/api/v1/channel-account/event") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); mockMvc.perform(post("/api/v1/channel-account/event") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); assertThat(eventRecordMapper.selectCount(null)).isEqualTo(1); } } ``` - [ ] **Step 3: 创建测试 SQL 文件 `backend/mci-server/src/test/resources/sql/test-channel-account.sql`** ```sql DELETE FROM mci_channel_event_record; DELETE FROM mci_channel_account; DELETE FROM mci_tenant; INSERT INTO mci_tenant (id, tenant_code, tenant_name, status, create_time) VALUES (1, 'rescue', '救援', 1, NOW()); INSERT INTO mci_channel_account (id, tenant_code, account_type, channel_type, account_id, account_name, login_status, online_status, risk_level, status, create_time) VALUES (1, 'rescue', 3, 'wechat_personal', 'wx-bot-01', '测试号', 0, 0, 0, 1, NOW()); ``` - [ ] **Step 4: 运行 Java 测试** ```bash cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server ./mvnw test -Dtest=ChannelAccountEventControllerTest ``` Expected: BUILD SUCCESS / tests passed。 - [ ] **Step 5: Commit** ```bash git add backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountEventController.java \ backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelAccountEventControllerTest.java \ backend/mci-server/src/test/resources/sql/test-channel-account.sql git commit -m "feat(channel): add channel account event receiver endpoint" ``` --- ### Task 14: mci-server 发送流程传入 record_id **Files:** - Modify: `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java` - Modify: `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapter.java` - Test: `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapterTest.java` - [ ] **Step 1: 在 SendService.dispatchRecord 中把 record_id 与 tenant_code 放入 extra** 找到 `dispatchRecord` 方法中构造 `SendMessageRequest` 的位置,已有类似代码: ```java extra.put("account_id", account.getAccountId()); messageRequest.setExtra(extra); ``` 改为: ```java extra.put("account_id", account.getAccountId()); extra.put("record_id", record.getId()); extra.put("tenant_code", record.getTenantCode()); messageRequest.setExtra(extra); ``` - [ ] **Step 2: 修改 WechatPersonalChannelAdapter.sendMessage 传入 record_id** ```java private Map sendText(String accountId, String conversationId, String recordId, String tenantCode, Map content) { String text = getString(content, "text"); if (isBlank(text)) { throw new IllegalArgumentException("文本内容为空"); } Map body = new HashMap<>(); body.put("account_id", accountId); body.put("conversation_id", conversationId); body.put("text", text); body.put("record_id", recordId); body.put("tenant_code", tenantCode); return post("/v1/wechat-personal/send/text", body); } private Map sendImage(String accountId, String conversationId, String recordId, String tenantCode, Map content) { String imageUrl = getString(content, "image_url"); if (isBlank(imageUrl)) { throw new IllegalArgumentException("图片地址为空"); } Map body = new HashMap<>(); body.put("account_id", accountId); body.put("conversation_id", conversationId); body.put("image_url", imageUrl); body.put("record_id", recordId); body.put("tenant_code", tenantCode); return post("/v1/wechat-personal/send/image", body); } ``` 并在 `sendMessage` 中读取 `recordId` 与 `tenantCode`: ```java String recordId = getString(request.getExtra(), "record_id"); String tenantCode = defaultString(getString(request.getExtra(), "tenant_code")); ... if ("image".equals(contentType)) { response = sendImage(accountId, conversationId, recordId, tenantCode, request.getContent()); } else { response = sendText(accountId, conversationId, recordId, tenantCode, request.getContent()); } ``` - [ ] **Step 3: 修改响应解析,兼容 `accepted` 字段** channel-service 异步受理后返回 `{"accepted": true, "message_id": "..."}` 而不是 `{"success": true, ...}`。在 `sendMessage` 中把成功判断改为: ```java Boolean success = getBoolean(response, "success"); Boolean accepted = getBoolean(response, "accepted"); if (!Boolean.TRUE.equals(success) && !Boolean.TRUE.equals(accepted)) { result.setSuccess(false); result.setMessage("个人微信发送失败:" + getString(response, "error")); return result; } result.setSuccess(true); result.setChannelMessageId(getString(response, "message_id")); ``` - [ ] **Step 4: 更新 WechatPersonalChannelAdapterTest** 在 `shouldSendTextMessage` 和 `shouldSendImageMessage` 中: 1. 给 `extra` 增加 `record_id` 与 `tenant_code`: ```java extra.put("record_id", "1001"); extra.put("tenant_code", "rescue"); ``` 2. 把 mock 响应从 `success` 改为 `accepted`: ```java .setBody(toJson(Map.of( "accepted", true, "message_id", "msg-123"))) ``` 3. 断言请求体包含 `record_id` 与 `tenant_code`。 - [ ] **Step 5: 运行相关 Java 测试** ```bash cd /Users/marsal/Projects/sino-project/sino-mci ./mvnw test -pl backend/mci-channel-wechat-personal -Dtest=WechatPersonalChannelAdapterTest ./mvnw test -pl backend/mci-server -Dtest=SendServiceResolveTargetTest,SendEndToEndTest ``` Expected: PASS。 - [ ] **Step 6: Commit** ```bash git add backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java \ backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapter.java \ backend/mci-channel-wechat-personal/src/test/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapterTest.java git commit -m "feat(send): pass record_id and tenant_code, accept async response" ``` --- ## Phase 5: 集成与联调 ### Task 15: 更新 docker-compose 环境变量 **Files:** - Modify: `docker-compose.yml` - [ ] **Step 1: 给 channel-service service 增加环境变量与数据卷** ```yaml channel-service: build: ./channel-service environment: - MCI_SERVER_URL=http://mci-server:8080/msg-platform - EVENT_STORE_PATH=/app/data/events.db - MAX_RETRY=5 - BASE_RETRY_DELAY_SECONDS=5 - EVENT_RETENTION_DAYS=10 - EVENT_PUBLISH_INTERVAL_SECONDS=5 volumes: - channel-service-data:/app/data ``` - [ ] **Step 2: 新增顶层 volume** ```yaml volumes: channel-service-data: ``` - [ ] **Step 3: Commit** ```bash git add docker-compose.yml git commit -m "chore(docker): add channel-service event store config and volume" ``` --- ### Task 16: 端到端冒烟测试 - [ ] **Step 1: 启动基础设施与两个服务** ```bash cd /Users/marsal/Projects/sino-project/sino-mci docker-compose up -d mysql redis rabbitmq mci-server channel-service ``` - [ ] **Step 2: 在 mci-server 创建渠道账号并初始化登录** ```bash curl -X POST http://localhost:8080/msg-platform/api/v1/channel-account \ -H "X-Tenant-Code: rescue" \ -H "Content-Type: application/json" \ -d '{"accountType":3,"channelType":"wechat_personal","accountId":"wx-bot-01","accountName":"测试号"}' curl -X POST http://localhost:8080/msg-platform/api/v1/channel-account/init \ -H "X-Tenant-Code: rescue" \ -H "Content-Type: application/json" \ -d '{"channelType":"wechat_personal","accountType":3,"config":{"account_id":"wx-bot-01"}}' ``` - [ ] **Step 3: 调用发送接口** ```bash curl -X POST http://localhost:8080/msg-platform/api/v1/send \ -H "X-Tenant-Code: rescue" \ -H "Content-Type: application/json" \ -d '{ "target":{"type":"conversation","conversationId":1}, "content":{"type":"text","body":{"text":"hello"}}, "channelStrategy":{"priority":["wechat_personal"]}, "callbackUrl":"http://host.docker.internal:9999/callback" }' ``` - [ ] **Step 4: 验证 channel-service SQLite 中存在 send_result 事件,且 mci-server SendRecord 状态更新为成功** ```bash docker exec -it sino-mci-channel-service-1 sqlite3 /app/data/events.db "SELECT event_type, status FROM outbound_events;" ``` Expected: 至少一条 `send_result` / `delivered`。 - [ ] **Step 5: 提交联调记录(可选)** 若测试通过,无需代码变更;否则修复问题后提交。 --- ## Self-Review Checklist ### Spec Coverage | Spec 要求 | 对应 Task | |-----------|-----------| | channel-service SQLite 事件持久化 | Task 2 | | 单线程后台 worker | Task 4 | | 指数退避重试 + 死信 | Task 4 | | 心跳/登录/在线/发送结果/错误事件 | Task 6, 7, 8 | | 发送改为异步受理 | Task 8 | | 10 天数据清理 | Task 10 | | mci-server `/event` 接收端点 | Task 13 | | event_id 幂等 | Task 12, 13 | | record_id 透传 | Task 14 | | 状态字段映射 | Task 12 | | 风控处理 | Task 8 | | docker-compose 配置 | Task 15 | ### Placeholder Scan - 无 "TBD" / "TODO"。 - 每个代码步骤包含完整代码。 - 每个测试步骤包含可运行命令与期望输出。 ### Type Consistency - `OutboundEvent.status` 与 `attempt_count` 在 Task 3/4 中加入后,后续 store/publisher 均一致使用。 - `record_id` 在 SendTextRequest / SendImageRequest、SendService、API 路由、WechatPersonalChannelAdapter 中一致使用。 - `ChannelAccountEventRequest` 字段名与服务中 `getString/getInteger/getLong` 一致。 --- ## 执行方式 Plan complete and saved to `docs/superpowers/plans/2026-07-10-channel-service-mci-server-integration-plan.md`. Two execution options: **1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration. **2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints. Which approach?