Add FastAPI wechat app: config, wechat handler, run scripts, requirements, env template
This commit is contained in:
1 parent
b193b29e3a
commit
a2185104ad
8 files changed
+249
-7
No files matched your search
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
环境变量模板
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
1. 复制此文件为 .env
|
||||||
|
2. 填入你的实际值
|
||||||
|
3. .env 不会进 Git(已在 .gitignore 中排除)
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 微信测试号 - 从 https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login 获取
|
||||||
|
WECHAT_TOKEN=your_custom_token_here
|
||||||
|
WECHAT_APPID=your_appid_here
|
||||||
|
WECHAT_SECRET=your_secret_here
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
MYSQL_HOST=127.0.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=your_mysql_password_here
|
||||||
|
MYSQL_DB=wechat_api
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=your_redis_password_here
|
||||||
+18
-6
@@ -1,8 +1,20 @@
|
|||||||
venv/
|
# 环境变量(含密码密钥,不进 Git)
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.env
|
.env
|
||||||
*.ppk
|
|
||||||
id_ed25519
|
# Python
|
||||||
id_ed25519.pub
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# venv
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# 日志
|
||||||
*.log
|
*.log
|
||||||
@@ -1 +1,23 @@
|
|||||||
wechat-api
|
# 微信公众号扫码授权服务
|
||||||
|
|
||||||
|
基于 FastAPI 的微信公众号接口服务,支持服务器验证、消息接收与回复。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- FastAPI + Uvicorn
|
||||||
|
- MySQL 8.0
|
||||||
|
- Redis 6.2
|
||||||
|
- Nginx 反向代理
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务器上
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python run_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env`,填入实际值。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
配置管理 - 从 .env 文件读取敏感信息
|
||||||
|
.env 文件不进 Git(已在 .gitignore 中排除)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 加载 .env 文件
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# 微信测试号配置
|
||||||
|
WECHAT_TOKEN = os.getenv("WECHAT_TOKEN", "")
|
||||||
|
WECHAT_APPID = os.getenv("WECHAT_APPID", "")
|
||||||
|
WECHAT_SECRET = os.getenv("WECHAT_SECRET", "")
|
||||||
|
|
||||||
|
# MySQL 配置
|
||||||
|
MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
|
||||||
|
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
|
||||||
|
MYSQL_USER = os.getenv("MYSQL_USER", "root")
|
||||||
|
MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
|
||||||
|
MYSQL_DB = os.getenv("MYSQL_DB", "wechat_api")
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
|
||||||
|
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||||
|
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.0
|
||||||
|
httpx==0.27.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
本地开发启动脚本
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python run_local.py
|
||||||
|
|
||||||
|
或者直接用 uvicorn:
|
||||||
|
uvicorn wechat:app --reload --host 127.0.0.1 --port 8000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"wechat:app",
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=8000,
|
||||||
|
reload=True, # 代码改动自动重启,开发用
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
服务器部署启动脚本
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
source venv/bin/activate
|
||||||
|
python run_server.py
|
||||||
|
|
||||||
|
或者配成 systemd 服务(后面会做)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"wechat:app",
|
||||||
|
host="127.0.0.1", # 只监听本地,由 Nginx 反向代理
|
||||||
|
port=8000,
|
||||||
|
reload=False, # 生产不开 reload
|
||||||
|
workers=1, # 2G 内存跑 1 个 worker 够了
|
||||||
|
)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
微信公众号 FastAPI 应用
|
||||||
|
|
||||||
|
GET /wechat - 微信服务器验证(签名校验 + 返回 echostr)
|
||||||
|
POST /wechat - 接收微信推送的消息和事件
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Query, HTTPException
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from config import WECHAT_TOKEN
|
||||||
|
|
||||||
|
app = FastAPI(title="WeChat API", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(signature: str, timestamp: str, nonce: str) -> bool:
|
||||||
|
"""
|
||||||
|
微信签名验证:
|
||||||
|
1. 将 token、timestamp、nonce 三个参数字典序排序
|
||||||
|
2. 拼成一个字符串
|
||||||
|
3. sha1 加密
|
||||||
|
4. 和 signature 对比
|
||||||
|
"""
|
||||||
|
if not WECHAT_TOKEN:
|
||||||
|
return False
|
||||||
|
items = sorted([WECHAT_TOKEN, timestamp, nonce])
|
||||||
|
sha1 = hashlib.sha1("".join(items).encode()).hexdigest()
|
||||||
|
return sha1 == signature
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/wechat")
|
||||||
|
async def wechat_verify(
|
||||||
|
signature: str = Query(...),
|
||||||
|
timestamp: str = Query(...),
|
||||||
|
nonce: str = Query(...),
|
||||||
|
echostr: str = Query(...),
|
||||||
|
):
|
||||||
|
"""GET: 微信服务器验证回调地址有效性"""
|
||||||
|
if not verify_signature(signature, timestamp, nonce):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||||
|
# 验证通过,原样返回 echostr
|
||||||
|
return PlainTextResponse(content=echostr)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/wechat")
|
||||||
|
async def wechat_message(
|
||||||
|
request: Request,
|
||||||
|
signature: str = Query(...),
|
||||||
|
timestamp: str = Query(...),
|
||||||
|
nonce: str = Query(...),
|
||||||
|
openid: str = Query(...),
|
||||||
|
):
|
||||||
|
"""POST: 接收微信推送的消息和事件"""
|
||||||
|
# 验证签名
|
||||||
|
if not verify_signature(signature, timestamp, nonce):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||||
|
|
||||||
|
# 解析 XML 消息体
|
||||||
|
body = await request.body()
|
||||||
|
root = ET.fromstring(body)
|
||||||
|
|
||||||
|
msg_type = root.findtext("MsgType", "")
|
||||||
|
from_user = root.findtext("FromUserName", "") # 发送方(用户 openid)
|
||||||
|
to_user = root.findtext("ToUserName", "") # 接收方(公众号)
|
||||||
|
content = root.findtext("Content", "")
|
||||||
|
event = root.findtext("Event", "")
|
||||||
|
event_key = root.findtext("EventKey", "")
|
||||||
|
|
||||||
|
print(f"[WeChat] type={msg_type} from={from_user} event={event} content={content} key={event_key}")
|
||||||
|
|
||||||
|
# 处理事件推送
|
||||||
|
if msg_type == "event":
|
||||||
|
if event == "subscribe":
|
||||||
|
# 用户关注
|
||||||
|
return _reply_text(from_user, to_user, "欢迎关注!")
|
||||||
|
elif event == "unsubscribe":
|
||||||
|
# 用户取关
|
||||||
|
print(f"[WeChat] 用户取关: {from_user}")
|
||||||
|
return PlainTextResponse(content="success")
|
||||||
|
elif event == "SCAN":
|
||||||
|
# 已关注用户扫码
|
||||||
|
return _reply_text(from_user, to_user, f"扫码成功,场景值: {event_key}")
|
||||||
|
elif event == "CLICK":
|
||||||
|
# 菜单点击
|
||||||
|
return _reply_text(from_user, to_user, f"点击了: {event_key}")
|
||||||
|
|
||||||
|
# 处理文本消息
|
||||||
|
if msg_type == "text":
|
||||||
|
# 原样返回(echo 模式,方便测试)
|
||||||
|
return _reply_text(from_user, to_user, f"你说: {content}")
|
||||||
|
|
||||||
|
# 其他类型暂不处理
|
||||||
|
return _reply_text(from_user, to_user, "收到")
|
||||||
|
|
||||||
|
|
||||||
|
def _reply_text(from_user: str, to_user: str, content: str) -> PlainTextResponse:
|
||||||
|
"""构造文本回复 XML"""
|
||||||
|
xml = (
|
||||||
|
"<xml>"
|
||||||
|
f"<ToUserName><![CDATA[{from_user}]]></ToUserName>"
|
||||||
|
f"<FromUserName><![CDATA[{to_user}]]></FromUserName>"
|
||||||
|
f"<CreateTime>{int(time.time())}</CreateTime>"
|
||||||
|
"<MsgType><![CDATA[text]]></MsgType>"
|
||||||
|
f"<Content><![CDATA[{content}]]></Content>"
|
||||||
|
"</xml>"
|
||||||
|
)
|
||||||
|
return PlainTextResponse(content=xml, media_type="application/xml")
|
||||||
Reference in new issue
Block a user