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

+255
View File
@@ -0,0 +1,255 @@
"""
授权接口与核心业务逻辑
路由(MFC 侧):
POST /auth/create_scene 生成 scene_str、创建微信临时二维码并落库
GET /auth/status 轮询扫码授权结果
业务函数(微信事件侧,由 wechat.py 调用):
handle_scan() 处理扫码事件:建用户、发免费授权、绑定场景、签发会话
"""
import logging
import secrets
import aiomysql
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
import db
from config import FREE_AUTH_DAYS, SCENE_TTL_SECONDS, SESSION_TTL_HOURS
from wechat_api import create_temp_qrcode
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
SCENE_PREFIX = "pc_"
class CreateSceneRequest(BaseModel):
device_id: str
@router.post("/create_scene")
async def create_scene(payload: CreateSceneRequest):
"""生成唯一 scene_str,调用微信接口创建临时二维码,写入 auth_scenes"""
scene_str = SCENE_PREFIX + secrets.token_hex(16)
try:
qr_url = await create_temp_qrcode(scene_str, SCENE_TTL_SECONDS)
except Exception as exc:
# 微信接口不可用(appid/secret 未配置、网络异常、token 失效等)
logger.exception("创建二维码失败 scene_str=%s", scene_str)
raise HTTPException(status_code=502, detail=f"创建微信二维码失败: {exc}")
async with db.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"INSERT INTO auth_scenes (scene_str, device_id, status, created_at, expires_at) "
"VALUES (%s, %s, 'pending', NOW(), DATE_ADD(NOW(), INTERVAL %s SECOND))",
(scene_str, payload.device_id, SCENE_TTL_SECONDS),
)
return {"scene_str": scene_str, "qr_url": qr_url, "expires_in": SCENE_TTL_SECONDS}
@router.get("/status")
async def get_status(scene: str = Query(..., description="create_scene 返回的 scene_str")):
"""查询扫码授权状态:pending / authorized / expired / need_purchase"""
async with db.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT id, status, user_id, (expires_at > NOW()) AS not_expired "
"FROM auth_scenes WHERE scene_str = %s",
(scene,),
)
scene_row = await cur.fetchone()
if scene_row is None:
raise HTTPException(status_code=404, detail="scene 不存在")
# 尚未扫码:过期则惰性置为 expired
if scene_row["status"] in ("pending", "scanned"):
if not scene_row["not_expired"]:
await cur.execute(
"UPDATE auth_scenes SET status = 'expired' "
"WHERE id = %s AND status IN ('pending', 'scanned')",
(scene_row["id"],),
)
return {"status": "expired"}
return {"status": "pending"}
if scene_row["status"] == "expired":
return {"status": "expired"}
# 已扫码授权:判断用户当前是否有可用授权
auth_row = await _get_active_authorization(cur, scene_row["user_id"])
if auth_row is None:
# 免费已领过且无有效授权 → 引导充值(阶段 3)
return {"status": "need_purchase"}
await cur.execute(
"SELECT token, (expires_at > NOW()) AS not_expired FROM sessions "
"WHERE scene_id = %s ORDER BY id DESC LIMIT 1",
(scene_row["id"],),
)
session_row = await cur.fetchone()
if session_row is None or not session_row["not_expired"]:
return {"status": "expired"}
return {
"status": "authorized",
"session_token": session_row["token"],
"authorization": _serialize_authorization(auth_row),
}
# ---------------------------------------------------------------------------
# 业务逻辑
# ---------------------------------------------------------------------------
async def handle_scan(scene_str: str, openid: str) -> bool:
"""
处理扫码事件。
返回 True 表示本次扫码完成授权,False 表示场景无效、已过期或已被处理。
整个流程在事务内完成,并对 scene 行加排他锁,保证同一 scene 只被处理一次。
"""
async with db.acquire() as conn:
await conn.begin()
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT id, status, device_id, (expires_at > NOW()) AS not_expired "
"FROM auth_scenes WHERE scene_str = %s FOR UPDATE",
(scene_str,),
)
scene_row = await cur.fetchone()
if scene_row is None:
await conn.rollback()
return False
if scene_row["status"] == "authorized":
# 重复扫码:只处理第一次,后续忽略
await conn.rollback()
return False
if not scene_row["not_expired"]:
await cur.execute(
"UPDATE auth_scenes SET status = 'expired' "
"WHERE id = %s AND status IN ('pending', 'scanned')",
(scene_row["id"],),
)
await conn.commit()
return False
user_id = await _find_or_create_user(cur, openid)
await _grant_free_authorization(cur, user_id)
await cur.execute(
"UPDATE auth_scenes SET status = 'authorized', user_id = %s, authorized_at = NOW() "
"WHERE id = %s",
(user_id, scene_row["id"]),
)
token = secrets.token_urlsafe(32)
await cur.execute(
"INSERT INTO sessions (token, user_id, device_id, scene_id, created_at, expires_at) "
"VALUES (%s, %s, %s, %s, NOW(), DATE_ADD(NOW(), INTERVAL %s HOUR))",
(token, user_id, scene_row["device_id"], scene_row["id"], SESSION_TTL_HOURS),
)
await conn.commit()
logger.info("扫码授权成功 scene_str=%s openid=%s", scene_str, openid)
return True
except Exception:
await conn.rollback()
raise
async def _find_or_create_user(cur, openid: str) -> int:
"""按 openid 查找用户,不存在则创建,并刷新 last_seen_at"""
await cur.execute("SELECT id FROM users WHERE openid = %s", (openid,))
row = await cur.fetchone()
if row is None:
# INSERT IGNORE + 重查:并发扫码时避免唯一键冲突报错
await cur.execute(
"INSERT IGNORE INTO users (openid, created_at, last_seen_at, has_claimed_free) "
"VALUES (%s, NOW(), NOW(), 0)",
(openid,),
)
await cur.execute("SELECT id FROM users WHERE openid = %s", (openid,))
row = await cur.fetchone()
await cur.execute("UPDATE users SET last_seen_at = NOW() WHERE id = %s", (row["id"],))
return row["id"]
async def _grant_free_authorization(cur, user_id: int) -> None:
"""
首次关注赠送 7 天时间授权。
以 has_claimed_free 的条件更新作为幂等闸门:只有把 0 改成 1 的那一次才真正发授权。
若用户已有 active 授权(互斥原则),新授权以 pending 保存。
"""
await cur.execute(
"UPDATE users SET has_claimed_free = 1 WHERE id = %s AND has_claimed_free = 0",
(user_id,),
)
if cur.rowcount != 1:
return
status = "pending" if await _has_active_authorization(cur, user_id) else "active"
await cur.execute(
"INSERT INTO authorizations "
"(user_id, type, start_at, end_at, remaining_points, total_points, source, status, created_at, updated_at) "
"VALUES (%s, 'time', NOW(), DATE_ADD(NOW(), INTERVAL %s DAY), 0, 0, 'free', %s, NOW(), NOW())",
(user_id, FREE_AUTH_DAYS, status),
)
logger.info("已发放免费授权 user_id=%s days=%s status=%s", user_id, FREE_AUTH_DAYS, status)
async def _has_active_authorization(cur, user_id: int) -> bool:
await cur.execute(
"SELECT 1 FROM authorizations WHERE user_id = %s AND status = 'active' LIMIT 1",
(user_id,),
)
return await cur.fetchone() is not None
async def _get_active_authorization(cur, user_id: int):
"""取用户当前 active 授权;已失效的惰性置为 expired / exhausted 并返回 None"""
await cur.execute(
"SELECT id, type, end_at, remaining_points, total_points, "
"(end_at IS NOT NULL AND end_at > NOW()) AS time_valid "
"FROM authorizations WHERE user_id = %s AND status = 'active' ORDER BY id DESC LIMIT 1",
(user_id,),
)
row = await cur.fetchone()
if row is None:
return None
if row["type"] == "time":
if not row["time_valid"]:
await cur.execute(
"UPDATE authorizations SET status = 'expired' WHERE id = %s AND status = 'active'",
(row["id"],),
)
return None
return row
if row["remaining_points"] <= 0:
await cur.execute(
"UPDATE authorizations SET status = 'exhausted' WHERE id = %s AND status = 'active'",
(row["id"],),
)
return None
return row
def _serialize_authorization(row) -> dict:
return {
"type": row["type"],
"end_at": row["end_at"].isoformat() if row["end_at"] else None,
"remaining_points": row["remaining_points"],
}