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
+13
-10
@@ -1,13 +1,11 @@
|
||||
"""
|
||||
环境变量模板
|
||||
|
||||
使用方法:
|
||||
1. 复制此文件为 .env
|
||||
2. 填入你的实际值
|
||||
3. .env 不会进 Git(已在 .gitignore 中排除)
|
||||
|
||||
cp .env.example .env
|
||||
"""
|
||||
# 环境变量模板
|
||||
#
|
||||
# 使用方法:
|
||||
# 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
|
||||
@@ -25,3 +23,8 @@ MYSQL_DB=wechat_api
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
|
||||
# 业务配置
|
||||
FREE_AUTH_DAYS=7
|
||||
SCENE_TTL_SECONDS=300
|
||||
SESSION_TTL_HOURS=24
|
||||
@@ -0,0 +1,49 @@
|
||||
# CODEBUDDY.md
|
||||
|
||||
This file provides guidance to CodeBuddy Code when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
微信公众号扫码授权服务,基于 FastAPI。当前实现微信服务器验证(GET /wechat)与消息/事件接收(POST /wechat)。MySQL 与 Redis 已在配置层声明,但业务代码尚未使用。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 安装依赖(建议先创建/激活虚拟环境 venv)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 本地开发(uvicorn reload,监听 127.0.0.1:8000)
|
||||
python run_local.py
|
||||
# 等价于:
|
||||
uvicorn wechat:app --reload --host 127.0.0.1 --port 8000
|
||||
|
||||
# 服务器部署(不 reload,单 worker,监听 127.0.0.1:8000,由 Nginx 反代)
|
||||
python run_server.py
|
||||
|
||||
# 初始化本地配置
|
||||
cp .env.example .env # 然后填入真实值
|
||||
```
|
||||
|
||||
当前仓库没有测试框架、lint 或构建配置;如需运行单个测试,需先引入 pytest 等工具。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
config.py # 从 .env 读取配置(dotenv),模块级常量
|
||||
wechat.py # FastAPI app 本体:路由 + 签名校验 + XML 解析/构造
|
||||
run_local.py # 开发启动入口(reload=True)
|
||||
run_server.py # 生产启动入口(reload=False, workers=1)
|
||||
```
|
||||
|
||||
- **配置**:所有敏感值经 `config.py` 从 `.env` 读取,`.env` 已被 `.gitignore` 排除。`.env.example` 是字段模板。新增配置项需同时更新这两处。
|
||||
- **应用入口**:两个启动脚本均以 `"wechat:app"` 字符串形式加载 `wechat.py` 中的 `app`,因此模块名/对象名不可随意重命名。
|
||||
- **微信交互协议**:
|
||||
- 所有请求先经 `verify_signature()`(token+timestamp+nonce 字典序拼接后 SHA1 比对)校验,失败返回 403。
|
||||
- GET 校验通过后原样返回 `echostr`。
|
||||
- POST 解析微信推送的 XML(`MsgType`/`FromUserName`/`Event` 等),通过 `_reply_text()` 构造文本回复 XML 返回。新增消息类型处理应在 `wechat_message()` 的事件/消息分支中扩展。
|
||||
- **注意**:`_reply_text()` 中 ToUserName/FromUserName 是反置的(回复时收发方互换),这是微信协议要求。
|
||||
|
||||
## 约定
|
||||
|
||||
- 代码注释与文档字符串使用中文。
|
||||
- 生产环境仅监听 127.0.0.1,对外暴露依赖 Nginx 反向代理。
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
# 微信扫码授权服务 — 需求文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
为一个 Windows MFC 桌面程序提供微信扫码授权服务。用户通过微信扫码关注公众号(当前使用微信测试号),服务端据此判断授权状态,MFC 端轮询后决定是否放行。后续支持用户充值获得时长或积分。
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
- 操作系统:Alibaba Cloud Linux 3
|
||||
- Web 框架:Python 3.10+ / FastAPI
|
||||
- ASGI 服务器:Uvicorn
|
||||
- 反向代理:Nginx(已配置,Cloudflare Tunnel 作为当前 HTTPS 入口)
|
||||
- 数据库:MySQL 8.0(Docker 部署,监听 127.0.0.1:3306)
|
||||
- 微信侧:微信公众平台测试号(后续迁移正式服务号)
|
||||
|
||||
## 3. 微信测试号配置
|
||||
|
||||
在.env文件内.
|
||||
URL:`http://ethereal-realm.top/wechat` |
|
||||
|
||||
## 4. 系统架构
|
||||
|
||||
```
|
||||
MFC 客户端
|
||||
│ HTTPS
|
||||
▼
|
||||
Cloudflare Tunnel / Nginx
|
||||
│
|
||||
▼
|
||||
FastAPI 服务
|
||||
├── /wechat 接收微信事件推送与 URL 验证
|
||||
├── /auth/* MFC 请求授权相关接口
|
||||
└── /usage/* MFC 上报使用与扣减
|
||||
│
|
||||
▼
|
||||
MySQL
|
||||
```
|
||||
|
||||
服务端为唯一权威。MFC 端不缓存任何可信授权数据,仅保存会话令牌。
|
||||
|
||||
## 5. 数据库设计
|
||||
|
||||
### 5.1 users — 用户表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | BIGINT PK AUTO_INCREMENT | |
|
||||
| openid | VARCHAR(64) UNIQUE NOT NULL | 微信 OpenID |
|
||||
| nickname | VARCHAR(100) | 昵称(可选) |
|
||||
| created_at | DATETIME | 首次关注时间 |
|
||||
| last_seen_at | DATETIME | 最近活跃时间 |
|
||||
| has_claimed_free | TINYINT DEFAULT 0 | 是否已领过免费授权 |
|
||||
|
||||
### 5.2 authorizations — 授权表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | BIGINT PK AUTO_INCREMENT | |
|
||||
| user_id | BIGINT NOT NULL | 外键 users.id |
|
||||
| type | ENUM('time','points') | 授权类型 |
|
||||
| start_at | DATETIME NULL | 时间授权开始 |
|
||||
| end_at | DATETIME NULL | 时间授权结束 |
|
||||
| remaining_points | INT DEFAULT 0 | 积分余额 |
|
||||
| total_points | INT DEFAULT 0 | 积分总量 |
|
||||
| source | ENUM('free','purchase','admin') | 来源 |
|
||||
| status | ENUM('pending','active','expired','exhausted','cancelled') | 状态 |
|
||||
| created_at | DATETIME | |
|
||||
| updated_at | DATETIME | |
|
||||
|
||||
索引:`idx_user_status (user_id, status)`
|
||||
|
||||
### 5.3 auth_scenes — 扫码场景表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | BIGINT PK AUTO_INCREMENT | |
|
||||
| scene_str | VARCHAR(128) UNIQUE NOT NULL | 二维码场景值 |
|
||||
| device_id | VARCHAR(128) | MFC 设备标识 |
|
||||
| status | ENUM('pending','scanned','authorized','expired') | |
|
||||
| user_id | BIGINT NULL | 扫码用户 |
|
||||
| created_at | DATETIME | |
|
||||
| expires_at | DATETIME NOT NULL | 默认 300 秒 |
|
||||
| authorized_at | DATETIME NULL | |
|
||||
|
||||
索引:`idx_scene_status (scene_str, status)`
|
||||
|
||||
### 5.4 usage_logs — 使用日志
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | BIGINT PK AUTO_INCREMENT | |
|
||||
| user_id | BIGINT NOT NULL | |
|
||||
| device_id | VARCHAR(128) | |
|
||||
| authorization_id | BIGINT | 本次扣减的授权 |
|
||||
| cost_type | ENUM('time','points') | |
|
||||
| cost_points | INT DEFAULT 0 | 时间授权为 0 |
|
||||
| used_at | DATETIME | |
|
||||
|
||||
索引:`idx_user_time (user_id, used_at)`
|
||||
|
||||
### 5.5 orders — 订单表(阶段 3 使用)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | BIGINT PK AUTO_INCREMENT | |
|
||||
| order_no | VARCHAR(64) UNIQUE NOT NULL | |
|
||||
| user_id | BIGINT NOT NULL | |
|
||||
| amount | DECIMAL(10,2) NOT NULL | |
|
||||
| product_id | BIGINT | 商品 ID |
|
||||
| status | ENUM('pending','paid','failed','refunded') | |
|
||||
| paid_at | DATETIME NULL | |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
## 6. API 接口设计
|
||||
|
||||
### 6.1 微信侧
|
||||
|
||||
**GET /wechat**
|
||||
微信服务器 URL 验证。校验 signature,返回 echostr。
|
||||
|
||||
**POST /wechat**
|
||||
接收微信事件推送,解析 XML:
|
||||
|
||||
- `subscribe` 事件:EventKey 形如 `qrscene_<scene_str>`
|
||||
- `SCAN` 事件:EventKey 直接为 `<scene_str>`
|
||||
|
||||
处理逻辑见第 7 节。
|
||||
|
||||
### 6.2 MFC 侧
|
||||
|
||||
**POST /auth/create_scene**
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{ "device_id": "设备唯一标识" }
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"scene_str": "pc_xxxxx",
|
||||
"qr_url": "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=...",
|
||||
"expires_in": 300
|
||||
}
|
||||
```
|
||||
|
||||
说明:服务端生成唯一 scene_str,调用微信接口生成临时二维码,写入 auth_scenes,返回二维码图片 URL。
|
||||
|
||||
**GET /auth/status?scene=xxx**
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending | authorized | expired | need_purchase",
|
||||
"session_token": "当 status=authorized 时返回",
|
||||
"authorization": {
|
||||
"type": "time | points",
|
||||
"end_at": "2026-10-03T12:00:00",
|
||||
"remaining_points": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**POST /usage/consume**
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_token": "xxx",
|
||||
"device_id": "xxx"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"authorization": {
|
||||
"type": "time",
|
||||
"end_at": "2026-10-03T12:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
失败时:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"reason": "expired | exhausted | invalid_token"
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 核心业务流程
|
||||
|
||||
### 7.1 首次扫码授权
|
||||
|
||||
1. MFC 启动,检查本地 session_token,无则调用 `/auth/create_scene`
|
||||
2. MFC 显示二维码,每 2 秒轮询 `/auth/status`
|
||||
3. 用户微信扫码
|
||||
4. 微信推送事件到 `/wechat`,服务端解析 scene_str
|
||||
5. 服务端查找或创建 user(按 openid)
|
||||
6. 如果 `has_claimed_free = 0`:创建时间授权,`start_at = now()`,`end_at = now() + 7 天`,`source = free`,`status = active`,`has_claimed_free = 1`
|
||||
7. 如果 `has_claimed_free = 1`:读取该用户当前有效授权(active 状态)
|
||||
8. 将 auth_scenes.status 置为 `authorized`,绑定 user_id
|
||||
9. MFC 轮询到 authorized,拿到 session_token,关闭弹窗
|
||||
|
||||
### 7.2 再次扫码
|
||||
|
||||
触发条件:时间授权到期,或积分耗尽,或换设备。
|
||||
|
||||
流程同上,但服务端在步骤 6 时:
|
||||
|
||||
- 若无有效授权 → 返回 `need_purchase`
|
||||
- 若有有效授权 → 正常返回 authorized
|
||||
|
||||
### 7.3 每次使用扣减
|
||||
|
||||
1. MFC 调用 `/usage/consume`
|
||||
2. 服务端校验 session_token
|
||||
3. 查找该用户当前 active 授权
|
||||
4. 时间授权:检查 `now() < end_at`,未过期则记录 usage_logs,直接返回 ok
|
||||
5. 积分授权:事务内 `UPDATE authorizations SET remaining_points = remaining_points - 1, status = IF(remaining_points - 1 <= 0, 'exhausted', 'active') WHERE id = ? AND remaining_points > 0 AND status = 'active'`,影响行数 1 则记录 usage_logs 并返回 ok,否则返回 exhausted
|
||||
6. 时间授权到期或积分耗尽时,MFC 下次使用会收到失败,弹出二维码引导再次扫码
|
||||
|
||||
## 8. 授权规则
|
||||
|
||||
**互斥原则**:同一用户同一时刻最多只有一条 `status = active` 的授权。
|
||||
|
||||
**新授权下发规则**:
|
||||
|
||||
- 若用户当前无 active 授权 → 新授权直接 active
|
||||
- 若用户当前有 active 授权 → 新授权以 `status = pending` 保存,待当前授权到期或耗尽后,由定时任务或下次请求时激活
|
||||
|
||||
**免费授权**:仅限首次关注,每个 openid 一次,7 天时间授权。
|
||||
|
||||
**时间与积分不叠加**:用户同时拥有时间和积分授权时,以当前 active 的为准,另一条保持 pending。active 结束后自动切换。
|
||||
|
||||
## 9. 边界与异常处理
|
||||
|
||||
- scene_str 一次性:auth_scenes 被授权后 status 不再回到 pending
|
||||
- scene 过期:超过 expires_at 后 status 置为 expired,MFC 轮询返回 expired,提示刷新二维码
|
||||
- 重复扫码:同一 scene 被多次扫描,只处理第一次,后续忽略
|
||||
- 已关注用户扫码:必须处理 SCAN 事件(EventKey 无 qrscene_ 前缀)
|
||||
- 未关注用户扫码:处理 subscribe 事件(EventKey 有 qrscene_ 前缀)
|
||||
- 并发扣减:必须使用数据库事务,禁止应用层先读后写
|
||||
- session_token:随机生成,存服务端,有效期 24 小时,过期需重新扫码
|
||||
|
||||
## 10. 开发阶段划分
|
||||
|
||||
**阶段 1:最小闭环**
|
||||
|
||||
- 建库建表
|
||||
- 实现 `/wechat` 的 GET 验证与 POST 事件接收
|
||||
- 实现 `/auth/create_scene`、`/auth/status`
|
||||
- 实现首次关注赠送 7 天免费授权
|
||||
- 用 curl 或 Postman 模拟微信事件,验证状态流转
|
||||
|
||||
**阶段 2:扣减与再扫码**
|
||||
|
||||
- 实现 `/usage/consume`
|
||||
- 实现时间到期与积分耗尽后的再次扫码流程
|
||||
- 实现 pending 授权自动激活
|
||||
|
||||
**阶段 3:充值**
|
||||
|
||||
- 实现 orders 表与卡密兑换接口
|
||||
- 后续接入微信支付 Native 扫码
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
- 首次扫码关注后,MFC 能收到 authorized 并正常使用
|
||||
- 7 天后再次使用,MFC 收到 expired,弹出二维码
|
||||
- 已关注用户再次扫码,服务端能收到 SCAN 事件并正确识别
|
||||
- 积分扣减在并发请求下不会出现负数
|
||||
- scene_str 一次性,重复扫描不产生副作用
|
||||
- 所有授权判断均在服务端完成,MFC 本地无法伪造
|
||||
|
||||
## 12. 待确认
|
||||
|
||||
- 用户充值获得积分后,若当前处于时间授权 active 状态,积分授权是否进入 pending 等待(建议:是)
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -25,3 +25,8 @@ MYSQL_DB = os.getenv("MYSQL_DB", "wechat_api")
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
||||
|
||||
# 业务配置
|
||||
FREE_AUTH_DAYS = int(os.getenv("FREE_AUTH_DAYS", "7")) # 首次关注赠送天数
|
||||
SCENE_TTL_SECONDS = int(os.getenv("SCENE_TTL_SECONDS", "300")) # 二维码/scene 有效期(秒)
|
||||
SESSION_TTL_HOURS = int(os.getenv("SESSION_TTL_HOURS", "24")) # session_token 有效期(小时)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
MySQL 连接池管理(aiomysql)
|
||||
|
||||
用法:
|
||||
await init_pool() # 应用启动时调用
|
||||
async with acquire() as conn:
|
||||
...
|
||||
await close_pool() # 应用关闭时调用
|
||||
|
||||
连接池开启 autocommit,单条语句自动提交;需要事务时显式调用 conn.begin() / conn.commit()。
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
import aiomysql
|
||||
|
||||
from config import MYSQL_DB, MYSQL_HOST, MYSQL_PASSWORD, MYSQL_PORT, MYSQL_USER
|
||||
|
||||
_pool: Optional[aiomysql.Pool] = None
|
||||
|
||||
|
||||
async def init_pool() -> None:
|
||||
"""创建全局连接池"""
|
||||
global _pool
|
||||
_pool = await aiomysql.create_pool(
|
||||
host=MYSQL_HOST,
|
||||
port=MYSQL_PORT,
|
||||
user=MYSQL_USER,
|
||||
password=MYSQL_PASSWORD,
|
||||
db=MYSQL_DB,
|
||||
charset="utf8mb4",
|
||||
autocommit=True,
|
||||
minsize=1,
|
||||
maxsize=10,
|
||||
)
|
||||
|
||||
|
||||
async def close_pool() -> None:
|
||||
"""关闭连接池"""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
_pool.close()
|
||||
await _pool.wait_closed()
|
||||
_pool = None
|
||||
|
||||
|
||||
def get_pool() -> aiomysql.Pool:
|
||||
if _pool is None:
|
||||
raise RuntimeError("MySQL 连接池未初始化,请先调用 init_pool()")
|
||||
return _pool
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire():
|
||||
"""从连接池借出一个连接"""
|
||||
async with get_pool().acquire() as conn:
|
||||
yield conn
|
||||
@@ -2,3 +2,5 @@ fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
httpx==0.27.0
|
||||
python-dotenv==1.0.1
|
||||
aiomysql==0.2.0
|
||||
cryptography==43.0.1
|
||||
@@ -0,0 +1,99 @@
|
||||
-- 微信扫码授权服务 — 数据库结构(阶段 1)
|
||||
-- 目标环境:MySQL 8.0
|
||||
-- 执行方式:mysql -u root -p < sql/schema.sql
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS `wechat_api`
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE `wechat_api`;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5.1 users — 用户表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`openid` VARCHAR(64) NOT NULL COMMENT '微信 OpenID',
|
||||
`nickname` VARCHAR(100) DEFAULT NULL COMMENT '昵称(可选)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次关注时间',
|
||||
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近活跃时间',
|
||||
`has_claimed_free` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已领过免费授权',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_openid` (`openid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5.2 authorizations — 授权表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `authorizations` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '外键 users.id',
|
||||
`type` ENUM('time','points') NOT NULL COMMENT '授权类型',
|
||||
`start_at` DATETIME DEFAULT NULL COMMENT '时间授权开始',
|
||||
`end_at` DATETIME DEFAULT NULL COMMENT '时间授权结束',
|
||||
`remaining_points` INT NOT NULL DEFAULT 0 COMMENT '积分余额',
|
||||
`total_points` INT NOT NULL DEFAULT 0 COMMENT '积分总量',
|
||||
`source` ENUM('free','purchase','admin') NOT NULL COMMENT '来源',
|
||||
`status` ENUM('pending','active','expired','exhausted','cancelled')
|
||||
NOT NULL DEFAULT 'pending' COMMENT '状态',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_status` (`user_id`, `status`),
|
||||
CONSTRAINT `fk_auth_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='授权表';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5.3 auth_scenes — 扫码场景表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `auth_scenes` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`scene_str` VARCHAR(128) NOT NULL COMMENT '二维码场景值',
|
||||
`device_id` VARCHAR(128) DEFAULT NULL COMMENT 'MFC 设备标识',
|
||||
`status` ENUM('pending','scanned','authorized','expired')
|
||||
NOT NULL DEFAULT 'pending' COMMENT '场景状态',
|
||||
`user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '扫码用户',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expires_at` DATETIME NOT NULL COMMENT '过期时间,默认 300 秒',
|
||||
`authorized_at` DATETIME DEFAULT NULL COMMENT '授权完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_scene_str` (`scene_str`),
|
||||
KEY `idx_scene_status` (`scene_str`, `status`),
|
||||
CONSTRAINT `fk_scene_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='扫码场景表';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5.4 usage_logs — 使用日志
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `usage_logs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`device_id` VARCHAR(128) DEFAULT NULL,
|
||||
`authorization_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '本次扣减的授权',
|
||||
`cost_type` ENUM('time','points') NOT NULL,
|
||||
`cost_points` INT NOT NULL DEFAULT 0 COMMENT '时间授权为 0',
|
||||
`used_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_time` (`user_id`, `used_at`),
|
||||
CONSTRAINT `fk_log_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_log_auth` FOREIGN KEY (`authorization_id`) REFERENCES `authorizations` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='使用日志';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- sessions — 会话令牌表(需求 9:session_token 服务端存储,有效期 24 小时)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `sessions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`token` VARCHAR(64) NOT NULL COMMENT '随机生成的会话令牌',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`device_id` VARCHAR(128) DEFAULT NULL COMMENT '签发时绑定的设备',
|
||||
`scene_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '签发来源场景',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expires_at` DATETIME NOT NULL COMMENT '过期时间,默认 24 小时',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_token` (`token`),
|
||||
KEY `idx_session_user` (`user_id`),
|
||||
KEY `idx_session_scene` (`scene_id`),
|
||||
CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_session_scene` FOREIGN KEY (`scene_id`) REFERENCES `auth_scenes` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话令牌表';
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
微信公众平台开放接口封装
|
||||
|
||||
- get_access_token(): 获取并缓存 access_token(有效期 7200 秒)
|
||||
- create_temp_qrcode(): 创建带字符串场景值的临时二维码,返回图片 URL
|
||||
|
||||
仅服务端调用,appid/secret 来自 .env。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from config import WECHAT_APPID, WECHAT_SECRET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://api.weixin.qq.com/cgi-bin"
|
||||
QRCODE_URL = "https://mp.weixin.qq.com/cgi-bin/showqrcode"
|
||||
|
||||
# access_token 内存缓存(单 worker 部署足够;提前 5 分钟过期避免边界失效)
|
||||
_token_cache = {"value": "", "expires_at": 0.0}
|
||||
|
||||
|
||||
async def get_access_token() -> str:
|
||||
"""获取 access_token,命中缓存则直接返回"""
|
||||
now = time.time()
|
||||
if _token_cache["value"] and now < _token_cache["expires_at"]:
|
||||
return _token_cache["value"]
|
||||
|
||||
if not WECHAT_APPID or not WECHAT_SECRET:
|
||||
raise RuntimeError("WECHAT_APPID / WECHAT_SECRET 未配置")
|
||||
|
||||
params = {
|
||||
"grant_type": "client_credential",
|
||||
"appid": WECHAT_APPID,
|
||||
"secret": WECHAT_SECRET,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{API_BASE}/token", params=params)
|
||||
data = resp.json()
|
||||
|
||||
if "access_token" not in data:
|
||||
raise RuntimeError(f"获取 access_token 失败: {data}")
|
||||
|
||||
_token_cache["value"] = data["access_token"]
|
||||
_token_cache["expires_at"] = now + int(data.get("expires_in", 7200)) - 300
|
||||
return _token_cache["value"]
|
||||
|
||||
|
||||
async def create_temp_qrcode(scene_str: str, expire_seconds: int) -> str:
|
||||
"""创建临时二维码(字符串场景值),返回扫码图片 URL"""
|
||||
token = await get_access_token()
|
||||
payload = {
|
||||
"expire_seconds": expire_seconds,
|
||||
"action_name": "QR_STR_SCENE",
|
||||
"action_info": {"scene": {"scene_str": scene_str}},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{API_BASE}/qrcode/create",
|
||||
params={"access_token": token},
|
||||
json=payload,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
ticket = data.get("ticket")
|
||||
if not ticket:
|
||||
# 40001 等 token 失效错误:清空缓存,下次重新获取
|
||||
if data.get("errcode") in (40001, 42001):
|
||||
_token_cache["value"] = ""
|
||||
raise RuntimeError(f"创建二维码失败: {data}")
|
||||
|
||||
logger.info("已创建临时二维码 scene_str=%s", scene_str)
|
||||
return f"{QRCODE_URL}?ticket={quote(ticket)}"
|
||||
Reference in new issue
Block a user