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:
0
channel-service/app/__init__.py
Normal file
0
channel-service/app/__init__.py
Normal file
0
channel-service/app/api/__init__.py
Normal file
0
channel-service/app/api/__init__.py
Normal file
8
channel-service/app/api/health.py
Normal file
8
channel-service/app/api/health.py
Normal 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"}
|
||||
114
channel-service/app/api/monitor.py
Normal file
114
channel-service/app/api/monitor.py
Normal 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)
|
||||
53
channel-service/app/api/wechat_personal.py
Normal file
53
channel-service/app/api/wechat_personal.py
Normal 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)
|
||||
0
channel-service/app/clients/__init__.py
Normal file
0
channel-service/app/clients/__init__.py
Normal file
110
channel-service/app/clients/pywechat_client.py
Normal file
110
channel-service/app/clients/pywechat_client.py
Normal 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()
|
||||
16
channel-service/app/config.py
Normal file
16
channel-service/app/config.py
Normal 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()
|
||||
15
channel-service/app/main.py
Normal file
15
channel-service/app/main.py
Normal 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)
|
||||
0
channel-service/app/services/__init__.py
Normal file
0
channel-service/app/services/__init__.py
Normal file
32
channel-service/app/services/heartbeat_service.py
Normal file
32
channel-service/app/services/heartbeat_service.py
Normal 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)
|
||||
53
channel-service/app/services/login_service.py
Normal file
53
channel-service/app/services/login_service.py
Normal 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)
|
||||
33
channel-service/app/services/send_service.py
Normal file
33
channel-service/app/services/send_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user