feat: 阶段 1 最小闭环 - 建表 SQL、/wechat 事件、/auth 接口、首次关注免费授权
- sql/schema.sql: users/authorizations/auth_scenes/usage_logs + sessions 建表 - db.py: aiomysql 连接池,lifespan 内初始化与释放 - wechat_api.py: access_token 缓存 + 临时二维码创建 - auth.py: /auth/create_scene、/auth/status,handle_scan 事务内幂等处理扫码 - wechat.py: 接入 DB 生命周期,处理 subscribe/SCAN 事件;移除多余的 openid query 参数 - 首次关注赠送 7 天免费授权,has_claimed_free 条件更新保证幂等 - config.py/.env.example: 新增 FREE_AUTH_DAYS/SCENE_TTL_SECONDS/SESSION_TTL_HOURS
This commit is contained in:
1 parent
a2185104ad
commit
3790821ce0
10 files changed
+915
-32
No files matched your search
@@ -2,19 +2,43 @@
|
||||
微信公众号 FastAPI 应用
|
||||
|
||||
GET /wechat - 微信服务器验证(签名校验 + 返回 echostr)
|
||||
POST /wechat - 接收微信推送的消息和事件
|
||||
POST /wechat - 接收微信推送的消息和事件(subscribe / SCAN 触发扫码授权)
|
||||
|
||||
授权接口在 auth.py 中定义,通过 include_router 挂载。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, Query, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
import auth
|
||||
import db
|
||||
from config import WECHAT_TOKEN
|
||||
|
||||
app = FastAPI(title="WeChat API", version="0.1.0")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用启动时建立 MySQL 连接池,关闭时释放"""
|
||||
await db.init_pool()
|
||||
logger.info("MySQL 连接池已初始化")
|
||||
yield
|
||||
await db.close_pool()
|
||||
logger.info("MySQL 连接池已关闭")
|
||||
|
||||
|
||||
app = FastAPI(title="WeChat API", version="0.2.0", lifespan=lifespan)
|
||||
app.include_router(auth.router)
|
||||
|
||||
|
||||
def verify_signature(signature: str, timestamp: str, nonce: str) -> bool:
|
||||
@@ -52,17 +76,16 @@ async def wechat_message(
|
||||
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)
|
||||
if not body:
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
root = ET.fromstring(body)
|
||||
msg_type = root.findtext("MsgType", "")
|
||||
from_user = root.findtext("FromUserName", "") # 发送方(用户 openid)
|
||||
to_user = root.findtext("ToUserName", "") # 接收方(公众号)
|
||||
@@ -70,31 +93,57 @@ async def wechat_message(
|
||||
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}")
|
||||
logger.info(
|
||||
"收到微信推送 type=%s event=%s from=%s key=%s", msg_type, event, from_user, event_key
|
||||
)
|
||||
|
||||
# 处理事件推送
|
||||
if msg_type == "event":
|
||||
if event == "subscribe":
|
||||
# 用户关注
|
||||
return _reply_text(from_user, to_user, "欢迎关注!")
|
||||
elif event == "unsubscribe":
|
||||
# 用户取关
|
||||
print(f"[WeChat] 用户取关: {from_user}")
|
||||
# 未关注用户扫码关注:EventKey 形如 qrscene_<scene_str>
|
||||
return await _handle_scan_event(from_user, to_user, event_key, is_subscribe=True)
|
||||
if event == "SCAN":
|
||||
# 已关注用户扫码:EventKey 直接是 <scene_str>
|
||||
return await _handle_scan_event(from_user, to_user, event_key, is_subscribe=False)
|
||||
if event == "unsubscribe":
|
||||
logger.info("用户取关 openid=%s", from_user)
|
||||
return PlainTextResponse(content="success")
|
||||
elif event == "SCAN":
|
||||
# 已关注用户扫码
|
||||
return _reply_text(from_user, to_user, f"扫码成功,场景值: {event_key}")
|
||||
elif event == "CLICK":
|
||||
# 菜单点击
|
||||
if 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, "收到")
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
|
||||
async def _handle_scan_event(
|
||||
from_user: str, to_user: str, event_key: str, is_subscribe: bool
|
||||
) -> PlainTextResponse:
|
||||
"""解析场景值并完成扫码授权,回复用户处理结果"""
|
||||
scene_str = _parse_scene_key(event_key, is_subscribe)
|
||||
if not scene_str:
|
||||
# 无场景值的普通关注
|
||||
return _reply_text(from_user, to_user, "欢迎关注!")
|
||||
|
||||
authorized = await auth.handle_scan(scene_str, from_user)
|
||||
if authorized:
|
||||
return _reply_text(from_user, to_user, "授权成功,请返回电脑端继续操作。")
|
||||
return _reply_text(from_user, to_user, "二维码已失效或已被使用,请在电脑端刷新后重新扫码。")
|
||||
|
||||
|
||||
def _parse_scene_key(event_key: str, is_subscribe: bool) -> str:
|
||||
"""
|
||||
从 EventKey 中提取 scene_str。
|
||||
|
||||
subscribe 事件带 qrscene_ 前缀,SCAN 事件不带;无场景值时返回空串。
|
||||
"""
|
||||
if not event_key:
|
||||
return ""
|
||||
if is_subscribe:
|
||||
prefix = "qrscene_"
|
||||
return event_key[len(prefix):] if event_key.startswith(prefix) else ""
|
||||
return event_key
|
||||
|
||||
|
||||
def _reply_text(from_user: str, to_user: str, content: str) -> PlainTextResponse:
|
||||
|
||||
Reference in new issue
Block a user