init code
This commit is contained in:
parent
c38e871e38
commit
6714dc9fe8
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git:*)",
|
||||
"WebSearch",
|
||||
"Bash(python3 -m pip install -r requirements.txt)",
|
||||
"Bash(python3:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
CORPID=ww9f866d5bf5175e77
|
||||
SECRET=O8sMN29urjyetJB1kU8jy6NglF8eMmdz6UpSLpVL7No
|
||||
OPEN_KFID=kfc0ec0f3746ebd8b95
|
||||
CALLBACK_TOKEN=your_callback_token
|
||||
CALLBACK_AES_KEY=your_43_char_aes_key
|
||||
DATABASE_URL=postgresql+asyncpg://wechat_kf:wechat_kf@159.195.38.93:5432/wechat_kf
|
||||
109
README.md
109
README.md
|
|
@ -1,2 +1,111 @@
|
|||
# python-wechat-kf
|
||||
|
||||
微信客服 API 网页测试工具:监听用户消息、主动/被动发消息,消息存储到 PostgreSQL。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建 PostgreSQL 数据库
|
||||
|
||||
```bash
|
||||
createdb wechat_kf
|
||||
```
|
||||
|
||||
### 2. 配置 .env
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 填入真实配置:
|
||||
|
||||
```env
|
||||
# --- 企业微信基础信息(必填)---
|
||||
# 企业 ID,在企业微信管理后台 - 我的企业 页面底部查看
|
||||
CORPID=ww9f866d5bf5175e77
|
||||
|
||||
# 应用 Secret,在企业微信管理后台 - 微信客服 - 开发配置 中获取
|
||||
SECRET=your_secret_here
|
||||
|
||||
# 客服账号 ID,在企业微信管理后台 - 微信客服 - 客服账号 详情页查看
|
||||
OPEN_KFID=wkxxxxxxxxxxxxx
|
||||
|
||||
# --- 回调配置(可选,仅接收实时消息时需要)---
|
||||
# 自定义 3-32 位字符串,与后台配置的回调 Token 一致
|
||||
CALLBACK_TOKEN=my_token_123
|
||||
|
||||
# 企业微信后台随机生成的 43 位 EncodingAESKey
|
||||
CALLBACK_AES_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# --- 数据库配置 ---
|
||||
# 格式: postgresql+asyncpg://用户名:密码@主机:端口/数据库名
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/wechat_kf
|
||||
```
|
||||
|
||||
**数据库连接字符串** 格式:
|
||||
```
|
||||
postgresql+asyncpg://用户名:密码@主机地址:端口/数据库名
|
||||
```
|
||||
如果需要修改用户名、密码、主机或数据库名,直接改这个连接字符串即可。
|
||||
|
||||
### 3. 安装依赖并启动
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python run.py
|
||||
```
|
||||
|
||||
访问 http://localhost:8000 即可看到聊天界面。
|
||||
|
||||
---
|
||||
|
||||
## CALLBACK_TOKEN 和 CALLBACK_AES_KEY 的使用
|
||||
|
||||
这两个参数用于 **微信回调消息的加解密**,只在需要接收实时消息推送时才需要配置。
|
||||
|
||||
### 配置流程
|
||||
|
||||
1. 登录 [企业微信管理后台](https://work.weixin.qq.com)
|
||||
2. 进入 **微信客服** → 选中客服账号 → **开发配置**
|
||||
3. 在回调配置中:
|
||||
- **Token**:自定义填一个 3-32 位字符串(如 `my_token_123`),同时写入 `.env` 的 `CALLBACK_TOKEN`
|
||||
- **EncodingAESKey**:点击"随机生成",得到一个 **43 位字符串**,填入 `.env` 的 `CALLBACK_AES_KEY`
|
||||
- **回调 URL**:填写 `https://你的域名/webhook`(见下方本地测试方案)
|
||||
4. 保存后微信会立刻发送 GET 请求到回调 URL 进行验证
|
||||
|
||||
### 如果不配回调
|
||||
|
||||
不配回调不影响以下功能:
|
||||
- 点击页面的 **"同步拉取"** 按钮主动轮询获取消息
|
||||
- 在页面中 **发送消息** 给客户
|
||||
|
||||
---
|
||||
|
||||
## 本地测试方案
|
||||
|
||||
回调模式要求 URL 必须是 **公网 HTTPS**。本地开发可以用以下工具:
|
||||
|
||||
### ngrok(推荐)
|
||||
|
||||
```bash
|
||||
# 安装 ngrok 并启动
|
||||
ngrok http 8000
|
||||
|
||||
# 会得到一个公网 URL,例如 https://abc123.ngrok.io
|
||||
# 在微信管理后台填入 https://abc123.ngrok.io/webhook 即可
|
||||
```
|
||||
|
||||
### 其他方案
|
||||
|
||||
| 方案 | 说明 |
|
||||
|------|------|
|
||||
| **ngrok** | 免费,一行命令,推荐 |
|
||||
| **frp** | 需要一台有公网 IP 的服务器 |
|
||||
| **localhost.run** | `ssh -R 80:localhost:8000 localhost.run` |
|
||||
| **部署到公网服务器** | 直接部署到有 HTTPS 的服务器 |
|
||||
|
||||
### 不配回调也能测试
|
||||
|
||||
即使没有公网地址,也可以:
|
||||
1. 启动应用 → 打开页面 → 点击 **"同步拉取"** 获取历史消息
|
||||
2. 在输入框输入内容 → 点击 **"发送"** 主动给客户发消息
|
||||
3. 这是**轮询模式**,不需要公网、不需要回调配置
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
corpid: str = ""
|
||||
secret: str = ""
|
||||
open_kfid: str = ""
|
||||
callback_token: str = ""
|
||||
callback_aes_key: str = ""
|
||||
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/wechat_kf"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
from app.models.message import Base
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
from app.database import init_db
|
||||
from app.routes import webhook, api, pages
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="微信客服 API 测试工具", lifespan=lifespan)
|
||||
|
||||
# 静态文件
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
# 路由
|
||||
app.include_router(pages.router)
|
||||
app.include_router(webhook.router)
|
||||
app.include_router(api.router)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import Integer, String, Text, DateTime, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
msg_id: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
open_kfid: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
external_userid: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
servicer_userid: Mapped[str | None] = mapped_column(String(128))
|
||||
send_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
msgtype: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
origin: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
content: Mapped[str | None] = mapped_column(Text)
|
||||
raw_data: Mapped[dict | None] = mapped_column(JSONB)
|
||||
direction: Mapped[str] = mapped_column(String(16), default="inbound")
|
||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_messages_external_userid", "external_userid"),
|
||||
Index("idx_messages_send_time", "send_time"),
|
||||
Index("idx_messages_open_kfid", "open_kfid"),
|
||||
)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.services.message_service import (
|
||||
get_conversations,
|
||||
get_messages,
|
||||
send_and_save,
|
||||
save_messages,
|
||||
)
|
||||
from app.services.wechat_client import sync_msg
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class SendMsgRequest(BaseModel):
|
||||
external_userid: str
|
||||
content: str
|
||||
open_kfid: str = ""
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
cursor: str = ""
|
||||
token: str = ""
|
||||
limit: int = 100
|
||||
open_kfid: str = ""
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(
|
||||
open_kfid: str = Query(""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话列表"""
|
||||
conversations = await get_conversations(db, open_kfid)
|
||||
return {"conversations": conversations}
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
async def list_messages(
|
||||
external_userid: str = Query(...),
|
||||
open_kfid: str = Query(""),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取某客户的消息列表"""
|
||||
messages = await get_messages(db, external_userid, open_kfid, page, page_size)
|
||||
return {"messages": messages}
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
async def send_message(
|
||||
req: SendMsgRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""发送消息"""
|
||||
result = await send_and_save(db, req.external_userid, req.content, req.open_kfid)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_messages(
|
||||
req: SyncRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""手动拉取消息(轮询模式)"""
|
||||
kfid = req.open_kfid or settings.open_kfid
|
||||
result = await sync_msg(kfid, req.cursor, req.token, req.limit)
|
||||
msg_list = result.get("msg_list", [])
|
||||
saved = 0
|
||||
if msg_list:
|
||||
saved = await save_messages(db, msg_list)
|
||||
return {
|
||||
**result,
|
||||
"saved": saved,
|
||||
"total": len(msg_list),
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
from fastapi import APIRouter, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
templates_dir = Path(__file__).parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_dir))
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
from fastapi import APIRouter, Request, Query, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.services.crypto import verify_url, decrypt_message
|
||||
from app.services.wechat_client import sync_msg
|
||||
from app.services.message_service import save_messages
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/webhook")
|
||||
async def verify_callback(
|
||||
msg_signature: str = Query(..., alias="msg_signature"),
|
||||
timestamp: str = Query(...),
|
||||
nonce: str = Query(...),
|
||||
echostr: str = Query(...),
|
||||
):
|
||||
"""URL 验证:解密 echostr 并返回明文"""
|
||||
try:
|
||||
plain = verify_url(msg_signature, timestamp, nonce, echostr)
|
||||
return PlainTextResponse(plain)
|
||||
except Exception as e:
|
||||
logger.error(f"URL 验证失败: {e}")
|
||||
return PlainTextResponse("verification failed", status_code=400)
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def receive_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""接收消息回调:解密 XML → 拉取消息 → 入库"""
|
||||
try:
|
||||
xml_body = await request.body()
|
||||
xml_str = xml_body.decode("utf-8")
|
||||
logger.info(f"收到回调: {xml_str[:200]}")
|
||||
|
||||
token, open_kfid = decrypt_message(xml_str)
|
||||
if not token or not open_kfid:
|
||||
logger.warning("解密后 Token 或 OpenKfId 为空")
|
||||
return PlainTextResponse("fail")
|
||||
|
||||
# 拉取消息
|
||||
result = await sync_msg(open_kfid, token=token, limit=100)
|
||||
msg_list = result.get("msg_list", [])
|
||||
if msg_list:
|
||||
saved = await save_messages(db, msg_list)
|
||||
logger.info(f"回调拉取消息 {len(msg_list)} 条,新增入库 {saved} 条")
|
||||
|
||||
return PlainTextResponse("success")
|
||||
except Exception as e:
|
||||
logger.error(f"回调处理失败: {e}")
|
||||
return PlainTextResponse("fail")
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _get_aes_key() -> bytes:
|
||||
"""将 43 位 EncodingAESKey 解码为 32 字节 AES Key"""
|
||||
key = settings.callback_aes_key
|
||||
# 43 位 Base64 → 补齐 = 号 → 解码得 32 字节
|
||||
return base64.b64decode(key + "=")
|
||||
|
||||
|
||||
def verify_signature(token: str, timestamp: str, nonce: str, encrypt_str: str,
|
||||
msg_signature: str) -> bool:
|
||||
"""验证消息签名"""
|
||||
params = sorted([token, timestamp, nonce, encrypt_str])
|
||||
raw = "".join(params)
|
||||
local_sig = hashlib.sha1(raw.encode()).hexdigest()
|
||||
return local_sig == msg_signature
|
||||
|
||||
|
||||
def decrypt(encrypt_str: str) -> str:
|
||||
"""AES-256-CBC 解密"""
|
||||
aes_key = _get_aes_key()
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv=aes_key[:16])
|
||||
raw = base64.b64decode(encrypt_str)
|
||||
plain = unpad(cipher.decrypt(raw), 16)
|
||||
|
||||
# 去掉前 16 字节随机串
|
||||
# 4 字节 msg_len(网络字节序)+ msg + corpid
|
||||
content = plain[16:]
|
||||
msg_len = struct.unpack("!I", content[:4])[0]
|
||||
msg = content[4:4 + msg_len].decode("utf-8")
|
||||
return msg
|
||||
|
||||
|
||||
def encrypt(plain_text: str) -> str:
|
||||
"""AES-256-CBC 加密"""
|
||||
aes_key = _get_aes_key()
|
||||
import os
|
||||
random_bytes = os.urandom(16)
|
||||
msg_bytes = plain_text.encode("utf-8")
|
||||
msg_len = struct.pack("!I", len(msg_bytes))
|
||||
corpid_bytes = settings.corpid.encode("utf-8")
|
||||
raw = random_bytes + msg_len + msg_bytes + corpid_bytes
|
||||
|
||||
from Crypto.Util.Padding import pad
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv=aes_key[:16])
|
||||
encrypted = cipher.encrypt(pad(raw, 16))
|
||||
return base64.b64encode(encrypted).decode()
|
||||
|
||||
|
||||
def verify_url(msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str:
|
||||
"""GET 请求:验证签名 + 解密 echostr"""
|
||||
token = settings.callback_token
|
||||
# 验证签名
|
||||
params = sorted([token, timestamp, nonce, echostr])
|
||||
raw = "".join(params)
|
||||
local_sig = hashlib.sha1(raw.encode()).hexdigest()
|
||||
if local_sig != msg_signature:
|
||||
raise ValueError("签名验证失败")
|
||||
return decrypt(echostr)
|
||||
|
||||
|
||||
def decrypt_message(xml_body: str) -> tuple[str, str]:
|
||||
"""POST 请求:解密 XML 消息,返回 (token, open_kfid)
|
||||
|
||||
注意:解密后的 XML 包含 <Token> 和 <OpenKfId> 等字段
|
||||
"""
|
||||
root = ET.fromstring(xml_body)
|
||||
encrypt_elem = root.find("Encrypt")
|
||||
if encrypt_elem is None or encrypt_elem.text is None:
|
||||
raise ValueError("XML 中缺少 Encrypt 字段")
|
||||
encrypt_str = encrypt_elem.text
|
||||
|
||||
# 解密
|
||||
plain_text = decrypt(encrypt_str)
|
||||
# 解析解密后的 XML
|
||||
plain_root = ET.fromstring(plain_text)
|
||||
|
||||
token = ""
|
||||
open_kfid = ""
|
||||
token_elem = plain_root.find("Token")
|
||||
kfid_elem = plain_root.find("OpenKfId")
|
||||
if token_elem is not None:
|
||||
token = token_elem.text or ""
|
||||
if kfid_elem is not None:
|
||||
open_kfid = kfid_elem.text or ""
|
||||
|
||||
return token, open_kfid
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import select, func, desc, and_
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.message import Message
|
||||
from app.services.wechat_client import send_msg
|
||||
from app.config import settings
|
||||
|
||||
|
||||
async def save_messages(db: AsyncSession, msg_list: list[dict]) -> int:
|
||||
"""批量保存消息,按 msg_id 去重,返回新增数量"""
|
||||
saved = 0
|
||||
for msg in msg_list:
|
||||
stmt = pg_insert(Message).values(
|
||||
msg_id=msg.get("msgid") or msg.get("msg_id", ""),
|
||||
open_kfid=msg.get("open_kfid", settings.open_kfid),
|
||||
external_userid=msg.get("external_userid", ""),
|
||||
servicer_userid=msg.get("servicer_userid"),
|
||||
send_time=datetime.fromtimestamp(msg.get("send_time", 0)),
|
||||
msgtype=msg.get("msgtype", "unknown"),
|
||||
origin=msg.get("origin", "customer"),
|
||||
content=_extract_text_content(msg),
|
||||
raw_data=msg,
|
||||
direction="inbound" if msg.get("origin") != "servicer" else "outbound",
|
||||
status="received",
|
||||
).on_conflict_do_nothing(index_elements=["msg_id"])
|
||||
result = await db.execute(stmt)
|
||||
if result.rowcount:
|
||||
saved += 1
|
||||
await db.commit()
|
||||
return saved
|
||||
|
||||
|
||||
async def get_conversations(db: AsyncSession, open_kfid: str = "", limit: int = 50) -> list[dict]:
|
||||
"""获取会话列表:按 external_userid 分组,显示最新消息"""
|
||||
kfid = open_kfid or settings.open_kfid
|
||||
# 子查询:每个客户的最新消息
|
||||
subq = (
|
||||
select(
|
||||
Message.external_userid,
|
||||
func.max(Message.send_time).label("latest_time")
|
||||
)
|
||||
.where(Message.open_kfid == kfid)
|
||||
.group_by(Message.external_userid)
|
||||
.order_by(desc("latest_time"))
|
||||
.limit(limit)
|
||||
.subquery()
|
||||
)
|
||||
q = (
|
||||
select(Message)
|
||||
.join(subq, and_(
|
||||
Message.external_userid == subq.c.external_userid,
|
||||
Message.send_time == subq.c.latest_time
|
||||
))
|
||||
.where(Message.open_kfid == kfid)
|
||||
.order_by(desc(Message.send_time))
|
||||
)
|
||||
result = await db.execute(q)
|
||||
rows = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"external_userid": r.external_userid,
|
||||
"latest_time": r.send_time.isoformat(),
|
||||
"latest_content": r.content or "",
|
||||
"latest_msgtype": r.msgtype,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_messages(db: AsyncSession, external_userid: str,
|
||||
open_kfid: str = "", page: int = 1,
|
||||
page_size: int = 50) -> list[dict]:
|
||||
"""获取某客户的消息列表(时间升序)"""
|
||||
kfid = open_kfid or settings.open_kfid
|
||||
offset = (page - 1) * page_size
|
||||
q = (
|
||||
select(Message)
|
||||
.where(
|
||||
Message.open_kfid == kfid,
|
||||
Message.external_userid == external_userid,
|
||||
)
|
||||
.order_by(Message.send_time.asc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
rows = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"msg_id": r.msg_id,
|
||||
"content": r.content,
|
||||
"msgtype": r.msgtype,
|
||||
"send_time": r.send_time.isoformat(),
|
||||
"origin": r.origin,
|
||||
"direction": r.direction,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def send_and_save(db: AsyncSession, external_userid: str, content: str,
|
||||
open_kfid: str = "") -> dict:
|
||||
"""发送消息并记录到数据库,返回发送结果"""
|
||||
kfid = open_kfid or settings.open_kfid
|
||||
result = await send_msg(
|
||||
touser=external_userid,
|
||||
open_kfid=kfid,
|
||||
msgtype="text",
|
||||
content=content,
|
||||
)
|
||||
errcode = result.get("errcode", -1)
|
||||
status = "sent" if errcode == 0 else "failed"
|
||||
|
||||
# 记录到数据库
|
||||
msg = Message(
|
||||
msg_id=result.get("msgid", f"out_{int(datetime.now().timestamp())}"),
|
||||
open_kfid=kfid,
|
||||
external_userid=external_userid,
|
||||
servicer_userid="",
|
||||
send_time=datetime.now(),
|
||||
msgtype="text",
|
||||
origin="servicer",
|
||||
content=content,
|
||||
raw_data=result,
|
||||
direction="outbound",
|
||||
status=status,
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
def _extract_text_content(msg: dict) -> str:
|
||||
"""从消息中提取文本内容"""
|
||||
if msg.get("msgtype") == "text":
|
||||
text_data = msg.get("text", {})
|
||||
return text_data.get("content", "") if isinstance(text_data, dict) else str(text_data)
|
||||
return ""
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import time
|
||||
import asyncio
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
# token 缓存
|
||||
_token_cache: dict = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_access_token() -> str:
|
||||
"""获取 access_token,带缓存和并发刷新保护"""
|
||||
now = int(time.time())
|
||||
if _token_cache.get("access_token") and _token_cache.get("expires_at", 0) > now + 300:
|
||||
return _token_cache["access_token"]
|
||||
|
||||
async with _lock:
|
||||
# 双重检查
|
||||
if _token_cache.get("access_token") and _token_cache.get("expires_at", 0) > now + 300:
|
||||
return _token_cache["access_token"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
resp = await client.get(url, params={
|
||||
"corpid": settings.corpid,
|
||||
"corpsecret": settings.secret,
|
||||
})
|
||||
data = resp.json()
|
||||
if data.get("errcode") != 0:
|
||||
raise Exception(f"获取 access_token 失败: {data}")
|
||||
|
||||
_token_cache["access_token"] = data["access_token"]
|
||||
_token_cache["expires_at"] = now + data["expires_in"]
|
||||
return _token_cache["access_token"]
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import httpx
|
||||
from app.services.token_manager import get_access_token
|
||||
|
||||
|
||||
WECHAT_API_BASE = "https://qyapi.weixin.qq.com"
|
||||
|
||||
|
||||
async def send_msg(touser: str, open_kfid: str, msgtype: str, content: str) -> dict:
|
||||
"""主动发送消息给客户"""
|
||||
token = await get_access_token()
|
||||
url = f"{WECHAT_API_BASE}/cgi-bin/kf/send_msg?access_token={token}"
|
||||
|
||||
body = {
|
||||
"touser": touser,
|
||||
"open_kfid": open_kfid,
|
||||
"msgtype": msgtype,
|
||||
}
|
||||
if msgtype == "text":
|
||||
body["text"] = {"content": content}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=body)
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def sync_msg(open_kfid: str, cursor: str = "", token: str = "", limit: int = 100) -> dict:
|
||||
"""拉取客服消息(轮询模式)"""
|
||||
access_token = await get_access_token()
|
||||
url = f"{WECHAT_API_BASE}/cgi-bin/kf/sync_msg?access_token={access_token}"
|
||||
|
||||
body = {
|
||||
"cursor": cursor,
|
||||
"token": token,
|
||||
"limit": limit,
|
||||
"open_kfid": open_kfid,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=body)
|
||||
return resp.json()
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// 状态
|
||||
let currentUser = "";
|
||||
let conversations = [];
|
||||
|
||||
// 初始化
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
loadConversations();
|
||||
});
|
||||
|
||||
// Toast
|
||||
function showToast(msg, type = "success") {
|
||||
const el = document.getElementById("toast");
|
||||
el.textContent = msg;
|
||||
el.className = "toast toast-" + type;
|
||||
el.style.display = "block";
|
||||
el.style.opacity = "1";
|
||||
setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.style.display = "none", 300); }, 2000);
|
||||
}
|
||||
|
||||
// 加载会话列表
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const resp = await fetch("/api/conversations");
|
||||
const data = await resp.json();
|
||||
conversations = data.conversations || [];
|
||||
renderConversations();
|
||||
|
||||
if (conversations.length === 0) {
|
||||
showToast("暂无会话,请先点击"同步拉取"获取消息", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("加载会话失败: " + e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染会话列表
|
||||
function renderConversations() {
|
||||
const list = document.getElementById("conversationList");
|
||||
if (conversations.length === 0) {
|
||||
list.innerHTML = '<div style="padding:20px;color:#999;text-align:center;">暂无会话</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = conversations.map(c => `
|
||||
<div class="conv-item ${c.external_userid === currentUser ? 'active' : ''}"
|
||||
onclick="selectConversation('${c.external_userid}')">
|
||||
<div class="userid">${c.external_userid}</div>
|
||||
<div class="preview">${escapeHtml(c.latest_content || '[非文本消息]')}</div>
|
||||
<div class="time">${formatTime(c.latest_time)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// 选择会话
|
||||
async function selectConversation(userid) {
|
||||
currentUser = userid;
|
||||
document.getElementById("msgHeader").textContent = "客户: " + userid;
|
||||
document.getElementById("sendBtn").disabled = false;
|
||||
document.getElementById("msgInput").disabled = false;
|
||||
renderConversations();
|
||||
await loadMessages(userid);
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
async function loadMessages(userid) {
|
||||
try {
|
||||
const resp = await fetch(`/api/messages?external_userid=${userid}`);
|
||||
const data = await resp.json();
|
||||
const msgs = data.messages || [];
|
||||
renderMessages(msgs);
|
||||
} catch (e) {
|
||||
showToast("加载消息失败: " + e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染消息
|
||||
function renderMessages(msgs) {
|
||||
const container = document.getElementById("messageList");
|
||||
if (msgs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">暂无消息</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = msgs.map(m => `
|
||||
<div class="msg-bubble ${m.direction === 'outbound' ? 'msg-outbound' : 'msg-inbound'}">
|
||||
<div>${escapeHtml(m.content || `[${m.msgtype}]`)}</div>
|
||||
<div class="msg-time">${formatTime(m.send_time)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage() {
|
||||
if (!currentUser) return;
|
||||
const input = document.getElementById("msgInput");
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
const btn = document.getElementById("sendBtn");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "发送中...";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ external_userid: currentUser, content }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.errcode === 0) {
|
||||
input.value = "";
|
||||
showToast("发送成功");
|
||||
await loadMessages(currentUser);
|
||||
} else {
|
||||
showToast("发送失败: " + (result.errmsg || JSON.stringify(result)), "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("发送失败: " + e.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "发送";
|
||||
}
|
||||
}
|
||||
|
||||
// 同步拉取
|
||||
async function syncMessages() {
|
||||
const statusEl = document.getElementById("syncStatus");
|
||||
statusEl.textContent = "同步中...";
|
||||
try {
|
||||
const resp = await fetch("/api/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.errcode === 0) {
|
||||
showToast(`同步成功,获取 ${data.total} 条消息,新增入库 ${data.saved} 条`);
|
||||
await loadConversations();
|
||||
if (currentUser) await loadMessages(currentUser);
|
||||
} else {
|
||||
showToast("同步失败: " + (data.errmsg || JSON.stringify(data)), "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("同步失败: " + e.message, "error");
|
||||
} finally {
|
||||
statusEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// HTML 转义
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>微信客服 - 消息测试</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100vh; display: flex; }
|
||||
/* 侧栏 */
|
||||
.sidebar {
|
||||
width: 320px; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; background: #f5f5f5;
|
||||
}
|
||||
.sidebar-header { padding: 16px; border-bottom: 1px solid #e0e0e0; background: #fff; }
|
||||
.sidebar-header h2 { font-size: 18px; margin-bottom: 8px; }
|
||||
.sidebar-header button { padding: 6px 16px; background: #07c160; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
.sidebar-header button:hover { background: #06ad56; }
|
||||
.conversation-list { flex: 1; overflow-y: auto; }
|
||||
.conv-item {
|
||||
padding: 14px 16px; cursor: pointer; border-bottom: 1px solid #e8e8e8; transition: background .15s;
|
||||
}
|
||||
.conv-item:hover { background: #ececec; }
|
||||
.conv-item.active { background: #d9d9d9; }
|
||||
.conv-item .userid { font-size: 14px; font-weight: 500; color: #333; }
|
||||
.conv-item .preview { font-size: 13px; color: #888; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conv-item .time { font-size: 12px; color: #aaa; margin-top: 2px; }
|
||||
/* 消息区域 */
|
||||
.main { flex: 1; display: flex; flex-direction: column; }
|
||||
.msg-header {
|
||||
padding: 16px; border-bottom: 1px solid #e0e0e0; font-size: 16px; font-weight: 500; background: #fff;
|
||||
}
|
||||
.msg-list { flex: 1; overflow-y: auto; padding: 16px; background: #ededed; display: flex; flex-direction: column; gap: 12px; }
|
||||
.msg-bubble {
|
||||
max-width: 70%; padding: 10px 14px; border-radius: 8px; font-size: 15px; line-height: 1.5; word-break: break-word;
|
||||
}
|
||||
.msg-inbound { align-self: flex-start; background: #fff; color: #333; }
|
||||
.msg-outbound { align-self: flex-end; background: #95ec69; color: #000; }
|
||||
.msg-time { font-size: 11px; color: #aaa; margin-top: 4px; }
|
||||
.empty-state { flex: 1; display: flex; align-items: center; justify-content: center; color: #999; font-size: 15px; }
|
||||
/* 输入区 */
|
||||
.input-area { padding: 16px; border-top: 1px solid #e0e0e0; background: #fff; display: flex; gap: 10px; }
|
||||
.input-area input { flex: 1; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 15px; outline: none; }
|
||||
.input-area input:focus { border-color: #07c160; }
|
||||
.input-area button { padding: 10px 24px; background: #07c160; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }
|
||||
.input-area button:hover { background: #06ad56; }
|
||||
.input-area button:disabled { background: #aaa; cursor: not-allowed; }
|
||||
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: 6px; color: #fff; font-size: 14px; z-index: 999; transition: opacity .3s; }
|
||||
.toast-success { background: #07c160; }
|
||||
.toast-error { background: #f44336; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 侧栏 -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>会话列表</h2>
|
||||
<button onclick="syncMessages()">同步拉取</button>
|
||||
<span id="syncStatus" style="font-size:12px;color:#888;margin-left:8px;"></span>
|
||||
</div>
|
||||
<div class="conversation-list" id="conversationList"></div>
|
||||
</div>
|
||||
<!-- 消息区 -->
|
||||
<div class="main">
|
||||
<div class="msg-header" id="msgHeader">请选择会话</div>
|
||||
<div class="msg-list" id="messageList">
|
||||
<div class="empty-state">选择左侧会话查看消息</div>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<input type="text" id="msgInput" placeholder="输入消息..." onkeydown="if(event.key==='Enter') sendMessage()">
|
||||
<button id="sendBtn" onclick="sendMessage()" disabled>发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast" style="display:none;"></div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# 任务执行摘要
|
||||
|
||||
## 会话 ID: 1
|
||||
- [2026-04-27 16:10:00]
|
||||
- **执行原因**: 实现微信客服 API 网页测试应用,支持监听用户消息、主动/被动发送消息、消息存储到 PostgreSQL
|
||||
- **执行过程**:
|
||||
1. 创建项目目录结构(app/models, app/services, app/routes, app/static, app/templates)。
|
||||
2. 实现配置层:config.py(pydantic-settings 读取 .env)和 .env.example 模板。
|
||||
3. 实现数据库层:database.py(AsyncEngine + session)和 models/message.py(Message ORM)。
|
||||
4. 实现微信 API 客户端:token_manager.py(access_token 内存缓存 + asyncio.Lock)和 wechat_client.py(send_msg / sync_msg)。
|
||||
5. 实现回调加解密:crypto.py(签名验证、AES-256-CBC 解密、URL 验证、消息解密)。
|
||||
6. 实现路由层:webhook.py(GET 验证 / POST 接收回调)、api.py(会话列表/消息查询/发送/同步)、pages.py(Jinja2 模板渲染)。
|
||||
7. 实现消息业务层:message_service.py(消息保存去重、会话列表、消息分页、发送并记录)。
|
||||
8. 实现前端界面:index.html(左侧会话列表 + 右侧消息气泡 + 底部输入框)和 app.js(原生 JS 交互)。
|
||||
9. 实现主入口:main.py(FastAPI 挂载路由 + 静态文件 + lifespan 建表)和 run.py(uvicorn 启动)。
|
||||
10. 安装依赖并验证完整导入链和路由注册。
|
||||
- **执行结果**: 项目全部 19 个源文件创建完成,导入链和路由验证通过。用户需创建 .env 文件配置微信参数后即可 `python run.py` 启动。
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
sqlalchemy[asyncio]>=2.0.30
|
||||
asyncpg>=0.29.0
|
||||
httpx>=0.27.0
|
||||
pycryptodome>=3.20.0
|
||||
pydantic-settings>=2.2.0
|
||||
jinja2>=3.1.3
|
||||
python-multipart>=0.0.9
|
||||
Loading…
Reference in New Issue