diff --git a/.gitignore b/.gitignore
index 7a32cc6..03fd31a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,16 @@ target/
.idea/
.vscode/
.DS_Store
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+.env
+
+# Node
+node_modules/
+dist/
+
diff --git a/channel-service/Dockerfile b/channel-service/Dockerfile
new file mode 100644
index 0000000..2bf6443
--- /dev/null
+++ b/channel-service/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app/ ./app/
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/channel-service/README.md b/channel-service/README.md
new file mode 100644
index 0000000..aea2434
--- /dev/null
+++ b/channel-service/README.md
@@ -0,0 +1,35 @@
+# sino-mci-channel-service
+
+Python 渠道服务,用于管理 pywechat 个人微信等渠道客户端。
+
+## 当前状态
+
+- 使用 **Mock pywechat 客户端**进行开发联调;真实 Windows 环境需接入 `pywechat`/`pywinauto` 驱动微信 PC 客户端。
+- 提供登录二维码模拟、消息发送模拟、心跳上报、客户端监控页面。
+
+## 本地运行
+
+```bash
+cd channel-service
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+uvicorn app.main:app --reload
+```
+
+## 测试
+
+```bash
+pytest
+```
+
+## 接口
+
+- `GET /health` 健康检查
+- `POST /v1/wechat-personal/login/start` 开始登录
+- `GET /v1/wechat-personal/login/{account_id}/status` 查询登录状态
+- `POST /v1/wechat-personal/login/{account_id}/confirm` 模拟扫码确认
+- `POST /v1/wechat-personal/send/text` 发送文本消息
+- `POST /v1/heartbeat` 客户端心跳
+- `GET /v1/monitor/status` 查询所有客户端状态
+- `GET /monitor` 客户端监控页面
diff --git a/channel-service/app/__init__.py b/channel-service/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/channel-service/app/api/__init__.py b/channel-service/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/channel-service/app/api/health.py b/channel-service/app/api/health.py
new file mode 100644
index 0000000..b96cc84
--- /dev/null
+++ b/channel-service/app/api/health.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health")
+def health_check():
+ return {"status": "ok", "service": "sino-mci-channel-service"}
diff --git a/channel-service/app/api/monitor.py b/channel-service/app/api/monitor.py
new file mode 100644
index 0000000..3058ad6
--- /dev/null
+++ b/channel-service/app/api/monitor.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from pydantic import BaseModel, Field
+
+from app.clients.pywechat_client import registry
+from app.services.heartbeat_service import heartbeat_service
+
+router = APIRouter(tags=["monitor"])
+
+
+class HeartbeatRequest(BaseModel):
+ account_id: str = Field(..., description="渠道账号唯一标识")
+ account_name: str | None = Field(None)
+ risk_level: int = Field(0, ge=0, le=100)
+ sent_count_today: int = Field(0, ge=0)
+
+
+@router.post("/v1/heartbeat")
+def heartbeat(request: HeartbeatRequest):
+ return heartbeat_service.heartbeat(request.account_id, request.model_dump(exclude_none=True))
+
+
+@router.get("/v1/monitor/status")
+def monitor_status():
+ clients = []
+ for client in registry.list_all():
+ state = client.get_state()
+ clients.append({
+ "account_id": state.account_id,
+ "account_name": state.account_name,
+ "login_status": state.login_status.value,
+ "online_status": state.online_status.value,
+ "last_heartbeat_at": state.last_heartbeat_at,
+ "risk_level": state.risk_level,
+ "sent_count_today": state.sent_count_today,
+ "error_message": state.error_message,
+ })
+ return {"clients": clients, "count": len(clients)}
+
+
+@router.get("/monitor", response_class=HTMLResponse)
+def monitor_page(request: Request):
+ html = """
+
+
+
+
+ pywechat 客户端监控
+
+
+
+ pywechat 客户端监控
+
+
+
+
+"""
+ return HTMLResponse(content=html)
diff --git a/channel-service/app/api/wechat_personal.py b/channel-service/app/api/wechat_personal.py
new file mode 100644
index 0000000..28853e6
--- /dev/null
+++ b/channel-service/app/api/wechat_personal.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from fastapi import APIRouter
+from pydantic import BaseModel, Field
+
+from app.services.login_service import login_service
+from app.services.send_service import send_service
+
+router = APIRouter(prefix="/v1/wechat-personal", tags=["wechat-personal"])
+
+
+class StartLoginRequest(BaseModel):
+ account_id: str = Field(..., description="渠道账号唯一标识")
+ account_name: Optional[str] = Field(None, description="账号显示名称")
+
+
+class SendTextRequest(BaseModel):
+ account_id: str
+ conversation_id: str = Field(..., description="目标会话/群 ID")
+ text: str = Field(..., min_length=1, description="消息内容")
+
+
+class SendImageRequest(BaseModel):
+ account_id: str
+ conversation_id: str
+ image_url: str
+
+
+@router.post("/login/start")
+def start_login(request: StartLoginRequest):
+ return login_service.start_login(request.account_id, request.account_name)
+
+
+@router.get("/login/{account_id}/status")
+def login_status(account_id: str):
+ return login_service.get_status(account_id)
+
+
+@router.post("/login/{account_id}/confirm")
+def confirm_login(account_id: str):
+ return login_service.confirm_login(account_id)
+
+
+@router.post("/send/text")
+def send_text(request: SendTextRequest):
+ return send_service.send_text(request.account_id, request.conversation_id, request.text)
+
+
+@router.post("/send/image")
+def send_image(request: SendImageRequest):
+ return send_service.send_image(request.account_id, request.conversation_id, request.image_url)
diff --git a/channel-service/app/clients/__init__.py b/channel-service/app/clients/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/channel-service/app/clients/pywechat_client.py b/channel-service/app/clients/pywechat_client.py
new file mode 100644
index 0000000..6480890
--- /dev/null
+++ b/channel-service/app/clients/pywechat_client.py
@@ -0,0 +1,110 @@
+"""pywechat client abstraction.
+
+The real implementation controls a Windows WeChat PC client via pywechat/pywinauto.
+This module provides a mock adapter for development and testing.
+"""
+
+from __future__ import annotations
+
+import time
+import uuid
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Optional
+
+
+class LoginStatus(str, Enum):
+ PENDING = "pending"
+ SCANNING = "scanning"
+ SUCCESS = "success"
+ EXPIRED = "expired"
+ FAILED = "failed"
+
+
+class OnlineStatus(str, Enum):
+ OFFLINE = "offline"
+ ONLINE = "online"
+ ERROR = "error"
+
+
+@dataclass
+class WeChatClientState:
+ account_id: str
+ account_name: Optional[str] = None
+ login_status: LoginStatus = LoginStatus.PENDING
+ online_status: OnlineStatus = OnlineStatus.OFFLINE
+ last_heartbeat_at: Optional[float] = None
+ login_id: Optional[str] = None
+ qr_code_url: Optional[str] = None
+ risk_level: int = 0
+ sent_count_today: int = 0
+ error_message: Optional[str] = None
+
+
+class PyWeChatClient:
+ """Mock pywechat client. Replace with real pywechat integration on Windows."""
+
+ def __init__(self, account_id: str, account_name: Optional[str] = None):
+ self.state = WeChatClientState(account_id=account_id, account_name=account_name)
+ self._conversations: dict[str, str] = {}
+
+ def start_login(self) -> str:
+ login_id = str(uuid.uuid4())
+ self.state.login_id = login_id
+ self.state.login_status = LoginStatus.PENDING
+ # In production this would return a real QR code from WeChat.
+ self.state.qr_code_url = f"https://mock.pywechat.qrcode/{login_id}"
+ return login_id
+
+ def confirm_login(self) -> None:
+ self.state.login_status = LoginStatus.SUCCESS
+ self.state.online_status = OnlineStatus.ONLINE
+ self.state.last_heartbeat_at = time.time()
+
+ def expire_login(self) -> None:
+ self.state.login_status = LoginStatus.EXPIRED
+ self.state.online_status = OnlineStatus.OFFLINE
+
+ def heartbeat(self) -> None:
+ self.state.last_heartbeat_at = time.time()
+ if self.state.online_status != OnlineStatus.ERROR:
+ self.state.online_status = OnlineStatus.ONLINE
+
+ def send_text(self, conversation_id: str, text: str) -> dict:
+ """Send a text message to a conversation (group or single chat).
+
+ In production this drives the WeChat PC client UI via pywechat.
+ """
+ self.state.sent_count_today += 1
+ return {
+ "success": True,
+ "message_id": f"mock-msg-{uuid.uuid4().hex[:16]}",
+ "conversation_id": conversation_id,
+ "text_preview": text[:50],
+ }
+
+ def get_state(self) -> WeChatClientState:
+ # Auto-expire login after 5 minutes if not confirmed.
+ if self.state.login_status == LoginStatus.PENDING and self.state.login_id:
+ # mock: auto-confirm after a few seconds for testing
+ pass
+ return self.state
+
+
+class ClientRegistry:
+ def __init__(self):
+ self._clients: dict[str, PyWeChatClient] = {}
+
+ def get_or_create(self, account_id: str, account_name: Optional[str] = None) -> PyWeChatClient:
+ if account_id not in self._clients:
+ self._clients[account_id] = PyWeChatClient(account_id, account_name)
+ return self._clients[account_id]
+
+ def get(self, account_id: str) -> Optional[PyWeChatClient]:
+ return self._clients.get(account_id)
+
+ def list_all(self) -> list[PyWeChatClient]:
+ return list(self._clients.values())
+
+
+registry = ClientRegistry()
diff --git a/channel-service/app/config.py b/channel-service/app/config.py
new file mode 100644
index 0000000..6eb59c8
--- /dev/null
+++ b/channel-service/app/config.py
@@ -0,0 +1,16 @@
+import os
+
+from pydantic_settings import BaseSettings
+
+
+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"
+
+
+settings = Settings()
diff --git a/channel-service/app/main.py b/channel-service/app/main.py
new file mode 100644
index 0000000..f4d7aaf
--- /dev/null
+++ b/channel-service/app/main.py
@@ -0,0 +1,15 @@
+from fastapi import FastAPI
+
+from app.api import health, monitor, wechat_personal
+from app.config import settings
+
+app = FastAPI(title=settings.app_name, debug=settings.debug)
+
+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)
diff --git a/channel-service/app/services/__init__.py b/channel-service/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/channel-service/app/services/heartbeat_service.py b/channel-service/app/services/heartbeat_service.py
new file mode 100644
index 0000000..e62f3d3
--- /dev/null
+++ b/channel-service/app/services/heartbeat_service.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+import time
+
+from app.clients.pywechat_client import ClientRegistry, OnlineStatus, registry
+
+
+class HeartbeatService:
+ def __init__(self, registry: ClientRegistry):
+ self.registry = registry
+
+ 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"]
+ return {"success": True, "account_id": account_id}
+
+ def check_offline_accounts(self, timeout_seconds: int = 120) -> list[dict]:
+ now = time.time()
+ offline = []
+ for client in self.registry.list_all():
+ last = client.state.last_heartbeat_at
+ if client.state.online_status == OnlineStatus.ONLINE and (last is None or now - last > timeout_seconds):
+ client.state.online_status = OnlineStatus.OFFLINE
+ offline.append({"account_id": client.state.account_id, "last_heartbeat_at": last})
+ return offline
+
+
+heartbeat_service = HeartbeatService(registry)
diff --git a/channel-service/app/services/login_service.py b/channel-service/app/services/login_service.py
new file mode 100644
index 0000000..43a2519
--- /dev/null
+++ b/channel-service/app/services/login_service.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import time
+from typing import Optional
+
+from app.clients.pywechat_client import ClientRegistry, LoginStatus, OnlineStatus, registry
+
+
+class LoginService:
+ def __init__(self, registry: ClientRegistry):
+ self.registry = registry
+
+ def start_login(self, account_id: str, account_name: Optional[str] = None) -> dict:
+ client = self.registry.get_or_create(account_id, account_name)
+ login_id = client.start_login()
+ return {
+ "login_id": login_id,
+ "qr_code_url": client.state.qr_code_url,
+ "status": client.state.login_status.value,
+ }
+
+ def get_status(self, account_id: str) -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return {"found": False, "account_id": account_id}
+ state = client.get_state()
+ return {
+ "found": True,
+ "account_id": state.account_id,
+ "account_name": state.account_name,
+ "login_status": state.login_status.value,
+ "online_status": state.online_status.value,
+ "last_heartbeat_at": state.last_heartbeat_at,
+ "risk_level": state.risk_level,
+ "sent_count_today": state.sent_count_today,
+ }
+
+ def confirm_login(self, account_id: str) -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return {"success": False, "error": "account not found"}
+ client.confirm_login()
+ return {"success": True, "status": client.state.login_status.value}
+
+ def expire_login(self, account_id: str) -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return {"success": False, "error": "account not found"}
+ client.expire_login()
+ return {"success": True, "status": client.state.login_status.value}
+
+
+login_service = LoginService(registry)
diff --git a/channel-service/app/services/send_service.py b/channel-service/app/services/send_service.py
new file mode 100644
index 0000000..4bc7447
--- /dev/null
+++ b/channel-service/app/services/send_service.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+from app.clients.pywechat_client import ClientRegistry, OnlineStatus, registry
+
+
+class SendService:
+ def __init__(self, registry: ClientRegistry):
+ self.registry = registry
+
+ def send_text(self, account_id: str, conversation_id: str, text: str) -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return {"success": False, "error": f"account {account_id} not registered"}
+ if client.state.online_status != OnlineStatus.ONLINE:
+ return {"success": False, "error": "account not online"}
+ return client.send_text(conversation_id, text)
+
+ def send_image(self, account_id: str, conversation_id: str, image_url: str) -> dict:
+ # Placeholder for image sending via pywechat.
+ client = self.registry.get(account_id)
+ if client is None:
+ return {"success": False, "error": f"account {account_id} not registered"}
+ if client.state.online_status != OnlineStatus.ONLINE:
+ return {"success": False, "error": "account not online"}
+ return {
+ "success": True,
+ "message_id": f"mock-img-{image_url.split('/')[-1]}",
+ "conversation_id": conversation_id,
+ "type": "image",
+ }
+
+
+send_service = SendService(registry)
diff --git a/channel-service/requirements.txt b/channel-service/requirements.txt
new file mode 100644
index 0000000..f2f029d
--- /dev/null
+++ b/channel-service/requirements.txt
@@ -0,0 +1,7 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+pydantic==2.10.4
+pydantic-settings==2.7.1
+httpx==0.28.1
+pytest==8.3.4
+pytest-asyncio==0.25.2
diff --git a/channel-service/tests/conftest.py b/channel-service/tests/conftest.py
new file mode 100644
index 0000000..1406b77
--- /dev/null
+++ b/channel-service/tests/conftest.py
@@ -0,0 +1,4 @@
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
diff --git a/channel-service/tests/test_api.py b/channel-service/tests/test_api.py
new file mode 100644
index 0000000..1c3c22e
--- /dev/null
+++ b/channel-service/tests/test_api.py
@@ -0,0 +1,70 @@
+from fastapi.testclient import TestClient
+
+from app.main import app
+
+client = TestClient(app)
+
+
+def test_health():
+ response = client.get("/health")
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+
+def test_login_flow():
+ r = client.post("/v1/wechat-personal/login/start", json={"account_id": "bot-001", "account_name": "测试号"})
+ assert r.status_code == 200
+ body = r.json()
+ assert body["login_id"]
+ assert body["qr_code_url"]
+
+ r = client.get("/v1/wechat-personal/login/bot-001/status")
+ assert r.status_code == 200
+ assert r.json()["login_status"] == "pending"
+
+ r = client.post("/v1/wechat-personal/login/bot-001/confirm")
+ assert r.status_code == 200
+ assert r.json()["success"] is True
+
+ r = client.get("/v1/wechat-personal/login/bot-001/status")
+ assert r.json()["login_status"] == "success"
+ assert r.json()["online_status"] == "online"
+
+
+def test_send_after_login():
+ client.post("/v1/wechat-personal/login/start", json={"account_id": "bot-002"})
+ client.post("/v1/wechat-personal/login/bot-002/confirm")
+
+ r = client.post("/v1/wechat-personal/send/text", json={
+ "account_id": "bot-002",
+ "conversation_id": "room-001",
+ "text": "hello"
+ })
+ assert r.status_code == 200
+ assert r.json()["success"] is True
+ assert r.json()["message_id"]
+
+
+def test_send_without_login_fails():
+ r = client.post("/v1/wechat-personal/send/text", json={
+ "account_id": "bot-offline",
+ "conversation_id": "room-001",
+ "text": "hello"
+ })
+ assert r.status_code == 200
+ assert r.json()["success"] is False
+
+
+def test_heartbeat_and_monitor():
+ client.post("/v1/heartbeat", json={"account_id": "bot-003", "risk_level": 10, "sent_count_today": 5})
+ r = client.get("/v1/monitor/status")
+ assert r.status_code == 200
+ clients = r.json()["clients"]
+ assert len(clients) >= 1
+ assert any(c["account_id"] == "bot-003" and c["risk_level"] == 10 for c in clients)
+
+
+def test_monitor_page():
+ r = client.get("/monitor")
+ assert r.status_code == 200
+ assert "channel-status-table" in r.text