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:
gjm committed 2026-09-26 22:25:02 +08:00
1 parent a2185104ad
commit 3790821ce0
10 files changed
+915 -32

No files matched your search

+71 -22
View File
@@ -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: