Files
wecom_it_smart_desk/backend/app/services/qrcode_service.py
T

487 lines
17 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 扫码登录业务服务
# =============================================================================
# 说明:封装扫码登录的核心业务逻辑,与 HTTP/路由层解耦。
# 关键设计:
# 1. Redis Key 设计:
# - qrcode:ticket:{ticket} → {created_at, expires_at}, TTL 120s
# - qrcode:scan:{ticket} → {employee_id, name, scanned_at}, TTL 120s
# - qrcode:confirm:{ticket} → {token, confirmed_at, roles}, TTL 60s
# 2. 状态机: waiting → scanned → confirmed → (poll 返回 token 后清空 confirm key)
# 3. dev 模式: 跳过企微 OAuth2,使用预设 dev 用户直接模拟扫码结果
# =============================================================================
import json
import logging
import os
import secrets
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from urllib.parse import urlencode
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# 常量
# --------------------------------------------------------------------------
# 票据有效期(秒): 与 Redis TTL 一致
TICKET_TTL_SECONDS = 120
# 扫码结果有效期(秒)
SCAN_TTL_SECONDS = 120
# 确认结果有效期(秒),用于前端轮询拿到 token
CONFIRM_TTL_SECONDS = 60
def _dev_mode_enabled() -> bool:
"""检查是否启用了开发模式。
三个检查源(任一为 true 即启用):
1. 环境变量 DEV_MODE=true
2. settings.dev_mode(从 .env.dev 读)
"""
if os.getenv("DEV_MODE", "false").lower() == "true":
return True
if getattr(settings, "dev_mode", False):
return True
return False
class QrcodeService:
"""扫码登录业务服务。
封装 Redis Key 管理、状态机、token 创建等核心逻辑。
实例方法都是 async,因为 Redis 操作是异步的。
Attributes:
redis: Redis 异步客户端
"""
def __init__(self, redis_client: aioredis.Redis):
"""初始化扫码登录服务。
Args:
redis_client: Redis 异步客户端
"""
self.redis = redis_client
# ------------------------------------------------------------------
# Key 辅助函数
# ------------------------------------------------------------------
@staticmethod
def _ticket_key(ticket: str) -> str:
"""获取票据状态 Key。
票据本身的存在性记录(120s TTL),用于判断票据是否过期。
"""
return f"qrcode:ticket:{ticket}"
@staticmethod
def _scan_key(ticket: str) -> str:
"""获取扫码结果 Key。
存放扫码后的企微用户信息(120s TTL),等待 confirm 端点消费。
"""
return f"qrcode:scan:{ticket}"
@staticmethod
def _confirm_key(ticket: str) -> str:
"""获取确认结果 Key。
存放 confirm 后的 token(60s TTL),供前端 poll 拿到后清空。
"""
return f"qrcode:confirm:{ticket}"
# ------------------------------------------------------------------
# create: 创建扫码登录票据
# ------------------------------------------------------------------
async def create_ticket(self) -> Dict[str, Any]:
"""创建扫码登录票据,返回 ticket + 企微 OAuth2 授权 URL。
流程:
1. 生成 UUID ticket
2. 写 Redis qrcode:ticket:{ticket} (TTL 120s)
3. 拼接企微 OAuth2 URL(state 参数传 ticket)
4. 返回 ticket / url / expires_at
Returns:
Dict: 包含 ticket / qrcode_url / expires_in / expires_at
"""
# 生成 ticket: 32 字符 URL 安全随机串
ticket = secrets.token_urlsafe(24)
now = datetime.now()
expires_at = now + timedelta(seconds=TICKET_TTL_SECONDS)
# 写 Redis 票据状态(只存时间戳,标明此 ticket 已创建)
ticket_payload = {
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
}
await self.redis.setex(
self._ticket_key(ticket),
TICKET_TTL_SECONDS,
json.dumps(ticket_payload, ensure_ascii=False),
)
# 拼接企微 OAuth2 授权 URL
# scope=snsapi_base: 静默授权,用户无感知(企微内部应用必须)
# state={ticket}: OAuth 回调时把 ticket 回传给我们的 scan 端点
qrcode_url = self._build_oauth_url(ticket)
logger.info(
f"扫码登录票据创建: ticket={ticket[:8]}..., expires_at={expires_at.isoformat()}"
)
return {
"ticket": ticket,
"qrcode_url": qrcode_url,
"expires_in": TICKET_TTL_SECONDS,
"expires_at": expires_at,
}
def _build_oauth_url(self, ticket: str) -> str:
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。
URL 格式:
https://open.weixin.qq.com/connect/oauth2/authorize
?appid={corp_id}
&redirect_uri={callback}
&response_type=code
&scope=snsapi_base
&state={ticket}
#wechat_redirect
Args:
ticket: 扫码登录票据
Returns:
str: 完整的 OAuth2 授权 URL
"""
# 回调地址: 当前后端的 auth_qrcode/scan 端点
# 企微要求 redirect_uri 必须 URL-encode
callback_url = self._get_scan_callback_url()
encoded_callback = callback_url # urlencode 留给前端做,这里假定配置已是合法 URL
params = {
"appid": settings.wecom_corp_id,
"redirect_uri": encoded_callback,
"response_type": "code",
"scope": "snsapi_base",
"state": ticket,
}
query = urlencode(params)
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
def _get_scan_callback_url(self) -> str:
"""获取 OAuth 回调地址。
优先使用 settings 里的配置;没有则用默认值 /api/auth_qrcode/scan。
当前没有这个配置,先用兜底;后续可在 Settings 加 qrcode_oauth_callback。
"""
# 兜底:相对路径,企微会带 Host 处理
return getattr(settings, "qrcode_oauth_callback", "/api/auth_qrcode/scan")
# ------------------------------------------------------------------
# scan: 处理企微 OAuth code 回调
# ------------------------------------------------------------------
async def process_scan(
self, ticket: str, code: str
) -> Dict[str, Any]:
"""处理扫码回调: 用 code 换 userid,写 Redis 供 confirm 端点消费。
流程:
1. 校验 ticket 存在(否则票据过期)
2. dev 模式 → 用预设 dev 用户跳过企微 API
3. 生产模式 → 调企微 get_oauth_user_info(code) 拿 userid
4. 再调 get_user_info(userid) 拿姓名
5. 写 Redis qrcode:scan:{ticket} (TTL 120s)
Args:
ticket: 扫码登录票据
code: 企微 OAuth2 授权 code
Returns:
Dict: 包含 success / message / employee_id / name
Raises:
ValueError: 票据过期或无效
"""
# 1. 校验 ticket 存在
ticket_data = await self.redis.get(self._ticket_key(ticket))
if not ticket_data:
logger.warning(f"扫码失败: ticket 已过期或不存在 ticket={ticket[:8]}...")
raise ValueError("扫码票据已过期或不存在")
# 2. 获取用户身份
employee_id = ""
name = ""
if _dev_mode_enabled():
# dev 模式: 用预设 dev 用户
# 提取 code 中的 userid(约定 dev 模式下 code 形如 "dev:dev-user-001")
employee_id, name = self._dev_extract_user(code)
logger.info(
f"[DEV] 扫码回调模拟: ticket={ticket[:8]}..., "
f"employee_id={employee_id}, name={name}"
)
else:
# 生产模式: 调企微 OAuth API
employee_id, name = await self._fetch_oauth_user(code)
# 3. 写 Redis 扫码结果(TTL 120s,等待 confirm 端点消费)
scan_payload = {
"employee_id": employee_id,
"name": name,
"scanned_at": datetime.now().isoformat(),
}
await self.redis.setex(
self._scan_key(ticket),
SCAN_TTL_SECONDS,
json.dumps(scan_payload, ensure_ascii=False),
)
logger.info(
f"扫码成功: ticket={ticket[:8]}..., employee_id={employee_id}, name={name}"
)
return {
"success": True,
"message": "扫码成功,等待用户确认",
"employee_id": employee_id,
"name": name,
}
def _dev_extract_user(self, code: str) -> tuple[str, str]:
"""dev 模式专用: 从 code 字符串提取 userid。
约定 code 格式:
- "dev:dev-user-001" → ("dev-user-001", "张三(普通员工)")
- "dev:dev-agent-001" → ("dev-agent-001", "李四(IT 坐席)")
- 其他 → 兜底用 settings.dev_default_userid
Args:
code: 企微 OAuth code(dev 模式下是 dev 约定串)
Returns:
tuple[str, str]: (employee_id, name)
"""
# dev 模式预设用户表(与 dev_auth.py 保持一致)
DEV_USERS = {
"dev-user-001": ("dev-user-001", "张三(普通员工)"),
"dev-agent-001": ("dev-agent-001", "李四(IT 坐席)"),
"dev-admin-001": ("dev-admin-001", "钱七(系统管理员)"),
}
if code.startswith("dev:"):
user_id = code[4:]
if user_id in DEV_USERS:
return DEV_USERS[user_id]
# 兜底:用 settings 默认 dev 用户
return (
settings.dev_default_userid,
settings.dev_default_name,
)
async def _fetch_oauth_user(self, code: str) -> tuple[str, str]:
"""生产模式: 用企微 OAuth2 code 换取 userid 与 name。
对应企微 API:
1. GET /cgi-bin/auth/getuserinfo?access_token=...&code=...
{ userid, user_ticket }
2. GET /cgi-bin/user/get?access_token=...&userid=...
{ name, ... }
Args:
code: 企微 OAuth2 授权 code
Returns:
tuple[str, str]: (userid, name)
Raises:
RuntimeError: 企微 API 调用失败
"""
# 延迟导入:避免 dev 模式测试时触发不必要的网络初始化
from app.services.wecom_service import WecomService
# 用同一个 redis 客户端保证 access_token 缓存命中
wecom = WecomService(self.redis)
try:
oauth_info = await wecom.get_oauth_user_info(code)
user_id = oauth_info.get("userid", "")
if not user_id:
raise RuntimeError("企微 OAuth 返回的 userid 为空")
user_info = await wecom.get_user_info(user_id)
name = user_info.get("name", "")
return user_id, name
finally:
try:
await wecom.close()
except Exception:
pass
# ------------------------------------------------------------------
# confirm: 当前已登录用户确认授权,创建 token
# ------------------------------------------------------------------
async def process_confirm(
self,
ticket: str,
current_user_id: str,
current_user_name: str,
current_roles: list,
otp_code: Optional[str] = None,
) -> Dict[str, Any]:
"""处理确认授权: 把扫码用户身份变成可登录 Token。
流程:
1. 校验 ticket 存在
2. 校验 scan 结果存在(否则没人扫过这个码)
3. TODO (Phase 2.1): admin 角色校验 otp_code
4. 创建 TokenService token(roles 来自扫码用户,不是 current_user)
5. 写 Redis qrcode:confirm:{ticket} (TTL 60s) 供前端 poll 拿到
Args:
ticket: 扫码登录票据
current_user_id: 当前已登录用户的 ID(用于 admin 校验)
current_user_name: 当前已登录用户的姓名
current_roles: 当前已登录用户的角色
otp_code: OTP 动态码(admin 场景下可选)
Returns:
Dict: 包含 token / employee_id / name / roles / require_otp
Raises:
ValueError: 票据过期 / 未扫码
"""
# 1. 校验 ticket
if not await self.redis.get(self._ticket_key(ticket)):
raise ValueError("扫码票据已过期或不存在")
# 2. 校验 scan 结果
scan_data_raw = await self.redis.get(self._scan_key(ticket))
if not scan_data_raw:
raise ValueError("该二维码尚未被扫码或扫码已过期")
# 解析扫码用户身份
try:
scan_data = json.loads(scan_data_raw)
except json.JSONDecodeError:
logger.error(f"扫码数据解析失败: ticket={ticket[:8]}...")
raise ValueError("扫码数据异常")
employee_id = scan_data.get("employee_id", "")
name = scan_data.get("name", "")
if not employee_id:
raise ValueError("扫码数据缺少 employee_id")
# 3. TODO Phase 2.1: admin 场景下的 OTP 校验
# 当前 Phase 1.1 不强制,otp_code 字段仅作为预留
require_otp = False
if otp_code is not None and "admin" in current_roles:
# 预留接口,真实校验逻辑放在 Phase 2.1 实现
# 此处仅标记 require_otp=True 提示前端
require_otp = True
logger.info(
f"扫码确认收到 OTP(预留字段,Phase 2.1 校验): "
f"current_user={current_user_id}, otp_code={otp_code[:2]}..."
)
# 4. 创建 Token(用扫码用户身份,roles 默认为 agent)
from app.services.token_service import TokenService
token_service = TokenService(self.redis)
roles = ["agent"]
token = await token_service.create_token(
employee_id=employee_id,
name=name,
roles=roles,
login_source="qrcode",
)
# 5. 写 Redis confirm 结果(TTL 60s,前端轮询拿到后过期)
confirm_payload = {
"token": token,
"confirmed_at": datetime.now().isoformat(),
"roles": roles,
"employee_id": employee_id,
"name": name,
}
await self.redis.setex(
self._confirm_key(ticket),
CONFIRM_TTL_SECONDS,
json.dumps(confirm_payload, ensure_ascii=False),
)
logger.info(
f"扫码确认成功: ticket={ticket[:8]}..., "
f"employee_id={employee_id}, current_user={current_user_id}"
)
return {
"token": token,
"employee_id": employee_id,
"name": name,
"roles": roles,
"require_otp": require_otp,
}
# ------------------------------------------------------------------
# poll: 轮询扫码状态
# ------------------------------------------------------------------
async def get_poll_state(self, ticket: str) -> Dict[str, Any]:
"""查询票据当前状态。
优先级: confirmed > scanned > ticket exists(等待) > 不存在(过期)
Returns:
Dict: 包含 status / employee_id / name / token
"""
# 1. 先看 confirm 结果(最高优先级,确认即终态)
confirm_raw = await self.redis.get(self._confirm_key(ticket))
if confirm_raw:
try:
confirm_data = json.loads(confirm_raw)
return {
"status": "confirmed",
"employee_id": confirm_data.get("employee_id"),
"name": confirm_data.get("name"),
"token": confirm_data.get("token"),
}
except json.JSONDecodeError:
logger.warning(f"confirm 数据解析失败: ticket={ticket[:8]}...")
# 2. 看 scan 结果(已扫码未确认)
scan_raw = await self.redis.get(self._scan_key(ticket))
if scan_raw:
try:
scan_data = json.loads(scan_raw)
return {
"status": "scanned",
"employee_id": scan_data.get("employee_id"),
"name": scan_data.get("name"),
"token": None,
}
except json.JSONDecodeError:
logger.warning(f"scan 数据解析失败: ticket={ticket[:8]}...")
# 3. 看 ticket 本身(还在等待扫码)
if await self.redis.get(self._ticket_key(ticket)):
return {
"status": "waiting",
"employee_id": None,
"name": None,
"token": None,
}
# 4. ticket 也不存在 → 已过期/不存在
return {
"status": "expired",
"employee_id": None,
"name": None,
"token": None,
}