feat(auth): 统一四种登录 —— 短信演示码123456 + 微信扫码OAuth + 小程序扫码
- sms stub 固定演示码123456 - 微信扫码(开放平台标准OAuth): /auth/wx-qr/start|wx-callback(GET/POST)|wx-qr/poll - 小程序扫码: /auth/mp-qr/start|poll|confirm - /auth/status 返回四 login_modes;WECHAT_OPEN_APPID/SECRET、AUTH_WECHAT_QR、WECHAT_QR_REDIRECT_URI 配置
This commit is contained in:
+185
-4
@@ -12,6 +12,7 @@ import re
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ... import config
|
||||
from ..dependencies import get_current_user, get_db, extract_bearer_token
|
||||
@@ -31,12 +32,23 @@ from ..schemas.auth import (
|
||||
VerifyResponse,
|
||||
WxLoginRequest,
|
||||
WxPhoneRequest,
|
||||
MpQrConfirmRequest,
|
||||
)
|
||||
from ...infrastructure.repositories import Database
|
||||
from ...services import sms, wechat
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# 扫码登录会话(内存 + TTL;生产可换 Redis)
|
||||
wx_qr_store = wechat.WxQrSessionStore()
|
||||
|
||||
|
||||
def _wx_redirect_uri(request: Request) -> str:
|
||||
"""扫码授权回跳地址:显式配置优先,否则按请求 base_url 推导。"""
|
||||
if config.WECHAT_QR_REDIRECT_URI:
|
||||
return config.WECHAT_QR_REDIRECT_URI
|
||||
return str(request.base_url).rstrip("/") + "/auth/wx-callback"
|
||||
|
||||
def _parse_topics(raw) -> list:
|
||||
"""topics(JSON 文本/list) → list;失败兜底 []。"""
|
||||
if not raw:
|
||||
@@ -282,12 +294,18 @@ async def register(req: RegisterRequest, request: Request, db: Database = Depend
|
||||
|
||||
@router.get("/status", response_model=AuthStatusResponse, summary="认证状态")
|
||||
async def auth_status(db: Database = Depends(get_db)):
|
||||
"""前端登录页据此判断是否展示登录表单。本期登录模式:账号密码 + 手机号验证码;微信扫码预留(未开放)。"""
|
||||
"""前端登录页据此展示登录方式。四种:账号密码 / 短信验证码 / 微信扫码 / 小程序扫码。"""
|
||||
modes = ["password", "phone"]
|
||||
if config.AUTH_WECHAT_QR:
|
||||
modes.append("wechat") # 微信开放平台扫码(OAuth)
|
||||
if config.AUTH_WECHAT_LOGIN:
|
||||
modes.append("miniprogram") # 小程序扫码(wx.login → code2session)
|
||||
return AuthStatusResponse(
|
||||
enabled=config.AUTH_ENABLED,
|
||||
has_users=await db.users.has_users(),
|
||||
login_modes=["password", "phone"] + (["wechat"] if config.AUTH_WECHAT_LOGIN else []),
|
||||
login_modes=modes,
|
||||
wechat_login=config.AUTH_WECHAT_LOGIN,
|
||||
wx_qr=config.AUTH_WECHAT_QR,
|
||||
)
|
||||
|
||||
|
||||
@@ -465,7 +483,8 @@ async def phone_login(req: PhoneLoginRequest, request: Request, db: Database = D
|
||||
except sms.SmsError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
|
||||
user = await db.users.get_by_username(req.phone)
|
||||
# 手机号唯一:既按「登录名=手机号」查,也按「已绑定手机」查(微信等身份绑了手机的用户)。
|
||||
user = await db.users.find_by_phone(req.phone) or await db.users.get_by_username(req.phone)
|
||||
if user is None:
|
||||
user = await db.users.create(
|
||||
req.phone, password=secrets.token_hex(16),
|
||||
@@ -537,7 +556,7 @@ async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)):
|
||||
user = await db.users.get_by_wx_openid(req.openid)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="微信账号不存在,请先 wx-login")
|
||||
bound = await db.users.get_by_username(req.phone)
|
||||
bound = await db.users.find_by_phone(req.phone) or await db.users.get_by_username(req.phone)
|
||||
if bound is not None and bound["id"] != user["id"]:
|
||||
raise HTTPException(status_code=409, detail="该手机号已绑定其它账号")
|
||||
|
||||
@@ -547,3 +566,165 @@ async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)):
|
||||
detail=f"wx bind phone {req.phone}", user_id=user["id"],
|
||||
)
|
||||
return {**await db.users.to_profile(fresh), "phone": fresh.get("phone", "")}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 微信开放平台扫码登录(标准 OAuth:qr_start → 扫码 → wx-callback → poll)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def _find_or_create_wx_user(
|
||||
openid: str, request: Request, db: Database,
|
||||
) -> dict:
|
||||
"""按扫码登录的 openid(存 wx_openid)查找/创建用户(幂等,唯一)。"""
|
||||
user = await db.users.get_by_wx_openid(openid)
|
||||
if user is None:
|
||||
user = await db.users.create(
|
||||
f"wx_{openid[:24]}", password=secrets.token_hex(16),
|
||||
wx_openid=openid, nickname="微信用户",
|
||||
role="opc_member",
|
||||
source=_client_source(request, "", "wx_openid"), auth_type="wx_openid",
|
||||
register_ip=_client_ip(request),
|
||||
)
|
||||
if user.get("status") != "active":
|
||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/wx-qr/start", summary="发起微信扫码登录")
|
||||
async def wx_qr_start(request: Request, db: Database = Depends(get_db)):
|
||||
"""生成扫码登录会话:返回 scene + 授权链接(渲染二维码)。
|
||||
|
||||
- 前端把 ``qr_url`` 渲染成二维码展示;扫码后在微信内授权。
|
||||
- 授权回跳 ``/auth/wx-callback``,前端轮询 ``/auth/wx-qr/poll?scene=`` 取令牌。
|
||||
"""
|
||||
if not config.AUTH_WECHAT_QR:
|
||||
raise HTTPException(status_code=503, detail="微信扫码登录未开启")
|
||||
if not config.AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="认证未开启")
|
||||
scene = wechat.new_scene()
|
||||
redirect_uri = _wx_redirect_uri(request)
|
||||
qr_url = wechat.qr_connect_url(scene, redirect_uri)
|
||||
wx_qr_store.start(scene)
|
||||
return {
|
||||
"scene": scene,
|
||||
"qr_url": qr_url,
|
||||
"redirect_uri": redirect_uri,
|
||||
"stub": not (config.WECHAT_OPEN_APPID and config.WECHAT_OPEN_SECRET),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/wx-callback", summary="微信扫码授权回跳")
|
||||
async def wx_callback_get(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
"""微信扫码授权回跳:换 openid → 建/复用账号 → 签发令牌,存到 scene 会话。"""
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="缺少 code")
|
||||
try:
|
||||
openid = await wechat.oauth_code2openid(code)
|
||||
except wechat.WechatError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
user = await _find_or_create_wx_user(openid, request, db)
|
||||
identity = await _ensure_opc_identity(db, user["id"])
|
||||
resp = await _login_response(db, user, identity)
|
||||
wx_qr_store.complete(state or openid, resp.token, resp.model_dump())
|
||||
return _wx_success_html()
|
||||
|
||||
|
||||
@router.post("/wx-callback", summary="微信扫码授权回跳(SPA 直交)")
|
||||
async def wx_callback_post(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
"""SPA 自行拿到 code 后 POST 到此:直接返回登录响应(不走轮询)。"""
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="缺少 code")
|
||||
try:
|
||||
openid = await wechat.oauth_code2openid(code)
|
||||
except wechat.WechatError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
user = await _find_or_create_wx_user(openid, request, db)
|
||||
identity = await _ensure_opc_identity(db, user["id"])
|
||||
return await _login_response(db, user, identity)
|
||||
|
||||
|
||||
@router.get("/wx-qr/poll", summary="轮询扫码登录状态")
|
||||
async def wx_qr_poll(scene: str):
|
||||
"""轮询扫码登录会话:done 时返回令牌+资料(一次性消费)。"""
|
||||
result = wx_qr_store.poll(scene)
|
||||
if result["status"] == "done":
|
||||
return {"status": "done", "token": result["token"], "profile": result["profile"]}
|
||||
return {"status": result["status"]}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 小程序扫码登录(web/桌面端展示小程序二维码 → 小程序内确认 → 轮询取令牌)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get("/mp-qr/start", summary="发起小程序扫码登录")
|
||||
async def mp_qr_start(request: Request, db: Database = Depends(get_db)):
|
||||
"""生成小程序扫码登录会话,返回 scene + 供渲染的链接。"""
|
||||
if not config.AUTH_WECHAT_LOGIN:
|
||||
raise HTTPException(status_code=503, detail="小程序登录未开启")
|
||||
if not config.AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="认证未开启")
|
||||
scene = wechat.new_scene()
|
||||
wx_qr_store.start(scene)
|
||||
# 小程序码需真实 wxacode;此处给可扫描的网页兜底链接 + 场景参数,
|
||||
# 真实小程序可通过 /auth/mp-qr/confirm 回传登录结果。前端据 qr_url 渲染二维码。
|
||||
qr_url = f"https://opc.pinesound.cn/mp/login?scene={scene}"
|
||||
return {"scene": scene, "qr_url": qr_url, "mp_enabled": True}
|
||||
|
||||
|
||||
@router.get("/mp-qr/poll", summary="轮询小程序扫码登录状态")
|
||||
async def mp_qr_poll(scene: str):
|
||||
result = wx_qr_store.poll(scene)
|
||||
if result["status"] == "done":
|
||||
return {"status": "done", "token": result["token"], "profile": result["profile"]}
|
||||
return {"status": result["status"]}
|
||||
|
||||
|
||||
@router.post("/mp-qr/confirm", summary="小程序内确认扫码登录")
|
||||
async def mp_qr_confirm(request: Request, req: MpQrConfirmRequest, db: Database = Depends(get_db)):
|
||||
"""小程序内 wx.login 后调用:以 scene 关联 web 端会话,回写已登录令牌。"""
|
||||
if not config.AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="认证未开启")
|
||||
try:
|
||||
openid = await wechat.code2session(req.code)
|
||||
except wechat.WechatError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
source = _client_source(request, "mini_program", "wx_openid")
|
||||
user = await db.users.get_by_wx_mini_openid(openid)
|
||||
if user is None:
|
||||
user = await db.users.create(
|
||||
f"wx_{openid[:24]}", password=secrets.token_hex(16),
|
||||
wx_mini_openid=openid, nickname="微信小程序用户",
|
||||
role="opc_member", source="mini_program", auth_type="wx_openid",
|
||||
register_ip=_client_ip(request),
|
||||
)
|
||||
if user.get("status") != "active":
|
||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||
identity = await _ensure_opc_identity(db, user["id"])
|
||||
resp = await _login_response(db, user, identity)
|
||||
wx_qr_store.complete(req.scene, resp.token, resp.model_dump())
|
||||
return {"ok": True, "username": user["username"]}
|
||||
|
||||
|
||||
def _wx_success_html() -> HTMLResponse:
|
||||
"""授权成功后给微信浏览器的简单页面(扫码端展示,闭环)。"""
|
||||
return HTMLResponse(
|
||||
"<!doctype html><html lang=zh><meta charset=utf-8>"
|
||||
"<meta name=viewport content='width=device-width'>"
|
||||
"<body style='display:flex;align-items:center;justify-content:center;"
|
||||
"height:100vh;font-family:sans-serif'><div style='text-align:center'>"
|
||||
"<div style='font-size:48px'>✅</div>"
|
||||
"<h3>微信登录成功</h3>"
|
||||
"<p>请返回原应用继续操作(本页可关闭)</p></div></body></html>"
|
||||
)
|
||||
|
||||
+10
-2
@@ -58,8 +58,9 @@ class RevokeTokenRequest(BaseModel):
|
||||
class AuthStatusResponse(BaseModel):
|
||||
enabled: bool
|
||||
has_users: bool
|
||||
login_modes: list[str] = ["password", "phone"] # 本期:账号密码 + 手机号验证码;微信扫码预留
|
||||
wechat_login: bool = False # 微信扫码(预留,本期未开放)
|
||||
login_modes: list[str] = ["password", "phone"] # password/phone/wechat/miniprogram(四种)
|
||||
wechat_login: bool = False # 小程序扫码(code2session)
|
||||
wx_qr: bool = False # 微信开放平台扫码(OAuth)
|
||||
|
||||
class VerifyResponse(BaseModel):
|
||||
valid: bool
|
||||
@@ -139,5 +140,12 @@ class WxPhoneRequest(BaseModel):
|
||||
code: str = Field(description="手机号接收到的验证码")
|
||||
|
||||
|
||||
class MpQrConfirmRequest(BaseModel):
|
||||
"""小程序扫码登录确认(小程序内 wx.login 后调用)。"""
|
||||
|
||||
scene: str = Field(description="web/桌面端 /auth/mp-qr/start 返回的场景")
|
||||
code: str = Field(description="wx.login 返回的 code")
|
||||
|
||||
|
||||
# RBAC 管理请求/响应
|
||||
|
||||
|
||||
+10
-1
@@ -130,10 +130,19 @@ SMS_CODE_TTL_SECONDS = int(os.environ.get("PINEAGENTS_SMS_CODE_TTL_SECONDS", "30
|
||||
SMS_RATE_LIMIT = int(os.environ.get("PINEAGENTS_SMS_RATE_LIMIT", "5"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 微信小程序登录(空 appid/secret 时把 code 直通当作 openid,便于无真实 appid 联调)
|
||||
# 微信登录(小程序 code2session + 开放平台网站应用扫码登录)
|
||||
# 未配置 appid/secret 时走「测试直通」:扫码登录把 code 当 openid 返回,便于无真实凭据联调。
|
||||
# WECHAT_APPID/SECRET 为小程序;WECHAT_OPEN_APPID/SECRET 为开放平台「网站应用」(扫码登录),缺省回退前者。
|
||||
# ---------------------------------------------------------------------------
|
||||
WECHAT_APPID = os.environ.get("PINEAGENTS_WECHAT_APPID", "")
|
||||
WECHAT_SECRET = os.environ.get("PINEAGENTS_WECHAT_SECRET", "")
|
||||
WECHAT_OPEN_APPID = os.environ.get("PINEAGENTS_WECHAT_OPEN_APPID", "") or WECHAT_APPID
|
||||
WECHAT_OPEN_SECRET = os.environ.get("PINEAGENTS_WECHAT_OPEN_SECRET", "") or WECHAT_SECRET
|
||||
# 扫码登录授权后的回跳地址(须在开放平台后台配置为该 URL):默认服务端 /auth/wx-callback。
|
||||
# 未设置时由前端 request.base_url 推导。
|
||||
WECHAT_QR_REDIRECT_URI = os.environ.get("PINEAGENTS_WECHAT_QR_REDIRECT_URI", "")
|
||||
# 扫码登录开关(true 才返回可用的 qr_start;false 时前端隐藏「微信扫码」入口)
|
||||
AUTH_WECHAT_QR = os.environ.get("PINEAGENTS_WECHAT_QR", "true").strip().lower() in ("true", "1", "yes")
|
||||
# 本期统一登录仅两模式(账号密码/手机号验证码);微信扫码预留后期,未开放(默认 False)。
|
||||
AUTH_WECHAT_LOGIN = os.environ.get("PINEAGENTS_WECHAT_LOGIN", "false").strip().lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
+10
-2
@@ -36,9 +36,17 @@ def _send(phone: str, code: str) -> None:
|
||||
raise SmsError(f"unsupported sms provider: {config.SMS_PROVIDER}")
|
||||
|
||||
|
||||
# 演示验证码:stub provider 统一用固定码(先跑通流程,生产换真实短信)。
|
||||
DEMO_CODE = "123456"
|
||||
|
||||
|
||||
def issue(phone: str) -> str:
|
||||
"""为手机号生成并发送 6 位验证码,返回明文(stub 下发时打印)。"""
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
"""为手机号生成并发送 6 位验证码,返回明文(stub 下发时打印)。
|
||||
|
||||
演示期 stub provider 统一返回固定码 ``123456``(见配置 SMS_PROVIDER=stub),
|
||||
便于四端联调;接入真实短信后按 provider 生成随机码。
|
||||
"""
|
||||
code = DEMO_CODE if config.SMS_PROVIDER == "stub" else f"{secrets.randbelow(1_000_000):06d}"
|
||||
with _LOCK:
|
||||
_STORE[phone] = {
|
||||
"code": code,
|
||||
|
||||
+114
-5
@@ -1,17 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""微信小程序登录服务。
|
||||
"""微信登录服务(小程序 code2session + 开放平台网站应用扫码登录)。
|
||||
|
||||
``code2session`` 用小程序 ``wx.login`` 的 code 换取 openid。
|
||||
未配置 ``WECHAT_APPID`` / ``WECHAT_SECRET`` 时走「测试直通」:直接把 code 当
|
||||
openid 返回,便于无真实微信 appid 的环境联调(生产必须配置真实凭据)。
|
||||
- ``code2session``:小程序 ``wx.login`` 的 code 换 openid。
|
||||
- ``qr_connect_url``:生成开放平台「网站应用扫码登录」授权链接(前端渲染成二维码)。
|
||||
- ``oauth_code2openid``:扫码授权回跳的 code 换 openid(OAuth2 access_token 端点)。
|
||||
|
||||
未配置对应凭据(小程序 WECHAT_APPID/SECRET、扫码 WECHAT_OPEN_APPID/SECRET)时走
|
||||
「测试直通」:把 code 当 openid 返回,便于无真实凭据联调(生产必须配置真实凭据)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import config
|
||||
|
||||
_WX_SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
_WX_OAUTH_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
_WX_QR_BASE_URL = "https://open.weixin.qq.com/connect/qrconnect"
|
||||
|
||||
|
||||
class WechatError(Exception):
|
||||
@@ -19,7 +29,7 @@ class WechatError(Exception):
|
||||
|
||||
|
||||
async def code2session(code: str) -> str:
|
||||
"""用登录 code 换取 openid。未配置 appid 时直通:``openid = code``。"""
|
||||
"""小程序登录 code 换 openid。未配置 appid 时直通:``openid = code``。"""
|
||||
code = code.strip()
|
||||
if not config.WECHAT_APPID or not config.WECHAT_SECRET:
|
||||
return code
|
||||
@@ -48,3 +58,102 @@ async def code2session(code: str) -> str:
|
||||
if not openid:
|
||||
raise WechatError("wechat code2session returned no openid")
|
||||
return openid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 开放平台「网站应用」扫码登录
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def qr_connect_url(state: str, redirect_uri: str) -> str:
|
||||
"""生成扫码登录授权链接(前端据其渲染二维码)。
|
||||
|
||||
扫码后在微信内完成授权 → 微信重定向到 ``redirect_uri?code=&state=``,
|
||||
服务端 ``/auth/wx-callback`` 收到后换 openid 并存到对应 state 会话。
|
||||
"""
|
||||
params = {
|
||||
"appid": config.WECHAT_OPEN_APPID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_login",
|
||||
"state": state,
|
||||
}
|
||||
return f"{_WX_QR_BASE_URL}?{urlencode(params)}#wechat_redirect"
|
||||
|
||||
|
||||
async def oauth_code2openid(code: str) -> str:
|
||||
"""扫码授权回跳的 code 换 openid。未配置开放平台凭据时直通:``openid = code + '_qr'``。"""
|
||||
code = code.strip()
|
||||
if not config.WECHAT_OPEN_APPID or not config.WECHAT_OPEN_SECRET:
|
||||
return f"{code}_qr"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
_WX_OAUTH_TOKEN_URL,
|
||||
params={
|
||||
"appid": config.WECHAT_OPEN_APPID,
|
||||
"secret": config.WECHAT_OPEN_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise WechatError(f"wechat oauth token request failed: {exc}") from exc
|
||||
|
||||
errcode = data.get("errcode", 0) or data.get("errCode", 0)
|
||||
if errcode:
|
||||
raise WechatError(f"wechat oauth error {errcode}: {data.get('errmsg', '')}")
|
||||
openid = data.get("openid")
|
||||
if not openid:
|
||||
raise WechatError("wechat oauth returned no openid")
|
||||
return openid
|
||||
|
||||
|
||||
class WxQrSessionStore:
|
||||
"""扫码登录会话:scene → {status, token, profile, expires_at}(内存 + TTL)。
|
||||
|
||||
- ``start``(scene):pending。
|
||||
- ``complete``(scene, token, profile):done(回调存登录结果)。
|
||||
- ``poll``(scene):done 时返回并消费(一次性),否则 pending/expired。
|
||||
进程重启即失效——生产可在 Redis 持久化(见架构 §8.4)。
|
||||
"""
|
||||
|
||||
_TTL = 120 # 会话有效期(秒)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._data: dict[str, dict] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self, scene: str) -> None:
|
||||
with self._lock:
|
||||
self._data[scene] = {
|
||||
"status": "pending", "token": "", "profile": None,
|
||||
"expires_at": time.monotonic() + self._TTL,
|
||||
}
|
||||
|
||||
def complete(self, scene: str, token: str, profile: dict) -> None:
|
||||
with self._lock:
|
||||
if scene in self._data:
|
||||
self._data[scene].update(
|
||||
{"status": "done", "token": token, "profile": profile},
|
||||
)
|
||||
|
||||
def poll(self, scene: str) -> dict:
|
||||
with self._lock:
|
||||
rec = self._data.get(scene)
|
||||
if not rec:
|
||||
return {"status": "expired"}
|
||||
if time.monotonic() > rec["expires_at"]:
|
||||
self._data.pop(scene, None)
|
||||
return {"status": "expired"}
|
||||
if rec["status"] == "done":
|
||||
# 消费一次(防重复取),但保留短暂窗口
|
||||
rec["status"] = "consumed"
|
||||
return {"status": "done", "token": rec["token"], "profile": rec["profile"]}
|
||||
return {"status": rec["status"], "token": "", "profile": None}
|
||||
|
||||
|
||||
def new_scene() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user