diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..958a135 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index bc7eecc..02bbf55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,20 @@ -venv/ -__pycache__/ -*.pyc +# 环境变量(含密码密钥,不进 Git) .env -*.ppk -id_ed25519 -id_ed25519.pub + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ + +# venv +venv/ +.venv/ + +# IDE +.vscode/ +.idea/ + +# 日志 *.log diff --git a/README.md b/README.md index 9a8469a..99172e0 100644 --- a/README.md +++ b/README.md @@ -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`,填入实际值。 diff --git a/config.py b/config.py new file mode 100644 index 0000000..2bafe71 --- /dev/null +++ b/config.py @@ -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", "") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..237bfa3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +httpx==0.27.0 +python-dotenv==1.0.1 diff --git a/run_local.py b/run_local.py new file mode 100644 index 0000000..655f9cb --- /dev/null +++ b/run_local.py @@ -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, # 代码改动自动重启,开发用 + ) diff --git a/run_server.py b/run_server.py new file mode 100644 index 0000000..405f73b --- /dev/null +++ b/run_server.py @@ -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 够了 + ) diff --git a/wechat.py b/wechat.py new file mode 100644 index 0000000..c62c9f3 --- /dev/null +++ b/wechat.py @@ -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 = ( + "" + f"" + f"" + f"{int(time.time())}" + "" + f"" + "" + ) + return PlainTextResponse(content=xml, media_type="application/xml")