From bd0ac464cf1b54cb6b2a8c523417dcf12e791814 Mon Sep 17 00:00:00 2001 From: marsal Date: Tue, 7 Jul 2026 12:36:54 +0800 Subject: [PATCH] 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 --- .gitignore | 13 ++ channel-service/Dockerfile | 12 ++ channel-service/README.md | 35 ++++++ channel-service/app/__init__.py | 0 channel-service/app/api/__init__.py | 0 channel-service/app/api/health.py | 8 ++ channel-service/app/api/monitor.py | 114 ++++++++++++++++++ channel-service/app/api/wechat_personal.py | 53 ++++++++ channel-service/app/clients/__init__.py | 0 .../app/clients/pywechat_client.py | 110 +++++++++++++++++ channel-service/app/config.py | 16 +++ channel-service/app/main.py | 15 +++ channel-service/app/services/__init__.py | 0 .../app/services/heartbeat_service.py | 32 +++++ channel-service/app/services/login_service.py | 53 ++++++++ channel-service/app/services/send_service.py | 33 +++++ channel-service/requirements.txt | 7 ++ channel-service/tests/conftest.py | 4 + channel-service/tests/test_api.py | 70 +++++++++++ 19 files changed, 575 insertions(+) create mode 100644 channel-service/Dockerfile create mode 100644 channel-service/README.md create mode 100644 channel-service/app/__init__.py create mode 100644 channel-service/app/api/__init__.py create mode 100644 channel-service/app/api/health.py create mode 100644 channel-service/app/api/monitor.py create mode 100644 channel-service/app/api/wechat_personal.py create mode 100644 channel-service/app/clients/__init__.py create mode 100644 channel-service/app/clients/pywechat_client.py create mode 100644 channel-service/app/config.py create mode 100644 channel-service/app/main.py create mode 100644 channel-service/app/services/__init__.py create mode 100644 channel-service/app/services/heartbeat_service.py create mode 100644 channel-service/app/services/login_service.py create mode 100644 channel-service/app/services/send_service.py create mode 100644 channel-service/requirements.txt create mode 100644 channel-service/tests/conftest.py create mode 100644 channel-service/tests/test_api.py 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