feat(channel-service): add Python FastAPI pywechat channel service

- Add FastAPI project under channel-service/
- Implement mock pywechat client adapter (login, send, heartbeat)
- Add /v1/wechat-personal/login/*, /v1/wechat-personal/send/* APIs
- Add /v1/heartbeat and /v1/monitor/status for client health
- Add /monitor HTML page with Web Components
- Add pytest suite and Dockerfile
- Update .gitignore for Python/Node artifacts
This commit is contained in:
2026-07-07 12:36:54 +08:00
parent df2d253c7b
commit bd0ac464cf
19 changed files with 575 additions and 0 deletions

13
.gitignore vendored
View File

@@ -6,3 +6,16 @@ target/
.idea/
.vscode/
.DS_Store
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.env
# Node
node_modules/
dist/

View File

@@ -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"]

35
channel-service/README.md Normal file
View File

@@ -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` 客户端监控页面

View File

View File

View File

@@ -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"}

View File

@@ -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 = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pywechat 客户端监控</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 20px; background: #f5f5f5; }
h1 { font-size: 20px; }
table { width: 100%; border-collapse: collapse; background: #fff; }
th, td { padding: 10px; border: 1px solid #ddd; text-align: left; }
th { background: #f0f0f0; }
.online { color: green; }
.offline { color: red; }
.pending { color: orange; }
</style>
</head>
<body>
<h1>pywechat 客户端监控</h1>
<channel-status-table></channel-status-table>
<script>
class ChannelStatusTable extends HTMLElement {
constructor() {
super();
this.render();
setInterval(() => this.refresh(), 5000);
}
async refresh() {
try {
const res = await fetch('/v1/monitor/status');
const body = await res.json();
this.render(body.clients || []);
} catch (e) {
this.innerHTML = '<p>加载失败:' + e.message + '</p>';
}
}
render(clients = []) {
if (!clients.length) {
this.innerHTML = '<p>暂无客户端</p>';
return;
}
const rows = clients.map(c => `
<tr>
<td>${c.account_id}</td>
<td>${c.account_name || '-'}</td>
<td class="${c.login_status}">${c.login_status}</td>
<td class="${c.online_status}">${c.online_status}</td>
<td>${c.risk_level}</td>
<td>${c.sent_count_today}</td>
<td>${c.last_heartbeat_at ? new Date(c.last_heartbeat_at * 1000).toLocaleString() : '-'}</td>
</tr>
`).join('');
this.innerHTML = `
<table>
<thead>
<tr><th>账号ID</th><th>名称</th><th>登录状态</th><th>在线状态</th><th>风控等级</th><th>今日发送</th><th>最后心跳</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
}
customElements.define('channel-status-table', ChannelStatusTable);
</script>
</body>
</html>"""
return HTMLResponse(content=html)

View File

@@ -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)

View File

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,4 @@
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

View File

@@ -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