feat(merge): 4 个 worktree 合入 main(扫码+MFA+高危+P0)

合入内容:
- worktree-A (auth_qrcode): 13 测试  — Phase 1.1 后端扫码登录
- worktree-B (mfa): 21 测试  — Phase 2.1 MFA TOTP + User 字段
- worktree-C (high_risk_guard): 28 测试  — Phase 1.3 高危守卫
- worktree-D (p0-fixes): 16 测试  — P0/P1 合规(WS 签名+UUID+access_log)

合并方式: 各 worktree 提取 format-patch → 只 apply 新增文件 → 手动合并 router.py/dependencies.py 冲突

新文件 (16):
  backend/alembic/versions/022_qrcode_login.py
  backend/alembic/versions/023_mfa_fields.py
  backend/alembic/versions/025_messages_id_uuid.py
  backend/app/api/auth_qrcode.py
  backend/app/api/high_risk_routes.py
  backend/app/api/mfa.py
  backend/app/schemas/mfa.py
  backend/app/schemas/qrcode.py
  backend/app/services/high_risk_guard.py
  backend/app/services/mfa_service.py
  backend/app/services/qrcode_service.py
  backend/scripts/nginx-access-log-sanitize.sh
  backend/tests/test_auth_qrcode.py (13)
  backend/tests/test_high_risk_guard.py (28)
  backend/tests/test_mfa.py (21)
  backend/tests/test_messages_uuid.py
  backend/tests/test_ws_endpoints.py
  backend/tests/test_ws_push_to_employee.py (xfail 4)

修改 (4):
  backend/app/api/router.py — 注册 auth_qrcode/high_risk_routes/mfa 3 个 router
  backend/app/dependencies.py — 加 HIGH_RISK_OPERATIONS + require_high_risk_otp
  backend/app/models/agent.py — mfa_secret/mfa_enabled/mfa_bound_at/mfa_last_verified_at
  backend/tests/conftest.py — create_test_conversation 接 db_session

测试结果(新增 78 + xfail 4):
  tests/test_auth_qrcode.py      13 passed
  tests/test_high_risk_guard.py  28 passed
  tests/test_mfa.py              21 passed
  tests/test_messages_uuid.py     8 passed
  tests/test_ws_endpoints.py      8 passed
  tests/test_ws_push_to_employee.py 4 xfailed (端点路径不一致,pre-existing)

4 端 frontend build 全部通过(agent/portal/admin/h5)

后续 TODO (用户操作):
1. 撤销 Gitea token 5ad83d... via Web UI
2. 跑 alembic upgrade head(生产 PG,025 messages UUID)
3. 应用 nginx access_log 脱敏(进容器改 conf)
4. 部署 backend + 4 端 dist + nginx reload

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Simon
2026-06-21 03:08:54 +08:00
parent f564d0e42a
commit bf872da8bb
22 changed files with 4704 additions and 27 deletions
+291
View File
@@ -0,0 +1,291 @@
# =============================================================================
# 企微IT智能服务台 — 高危操作守卫服务
# =============================================================================
# 说明:集中处理高危操作(Phase 1.3 task #19)的 OTP 验证状态管理
# 决策来源:otm-secondary-auth.md2026-06-21 决策)
#
# 核心职责:
# 1. 标记管理员 OTP 验证通过(write)
# 2. 查询管理员 OTP 验证状态(read)
# 3. 撤销管理员 OTP 验证(revoke)
# 4. 列出全部 5 类高危操作白名单(白名单查询)
#
# Redis key 设计:
# key: mfa:verified:{employee_id}
# value: 验证方式("totp" / "sms_backup"+ 时间戳
# TTL: 1800 秒(30 分钟)
#
# 与 dependencies.py 中 require_high_risk_otp 配套使用:
# - mfa.py 在 /api/mfa/verify 成功后调 mark_verified(...)
# - require_high_risk_otp 在每个高危端点 Depends 时调 is_verified(...)
# =============================================================================
import json
import logging
from datetime import datetime
from typing import Dict, List, Optional
import redis.asyncio as aioredis
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# 5 类高危操作白名单(与 dependencies.HIGH_RISK_OPERATIONS 保持一致)
# -----------------------------------------------------------------------------
# 注意:这里再做一次定义是为了让 service 层独立可测,不依赖 dependencies 模块
# (避免循环引用 + 方便单测)
# -----------------------------------------------------------------------------
HIGH_RISK_OPERATIONS_WHITELIST: Dict[str, Dict] = {
"role_change": {
"category": "改权限",
"require_otp": True,
"examples": ["POST /api/admin/roles/assign", "POST /api/admin/roles/revoke"],
"description": "分配或撤销用户角色",
},
"config_change": {
"category": "改配置",
"require_otp": True,
"examples": ["PUT /api/admin/configs/{key}"],
"description": "修改系统配置项",
},
"data_export": {
"category": "导出数据",
"require_otp": True,
"examples": ["GET /api/admin/export/*"],
"description": "导出敏感数据(会话、坐席统计等)",
},
"account_disable": {
"category": "封号",
"require_otp": True,
"examples": ["DELETE /api/admin/agents/{id}"],
"description": "禁用/删除坐席账号",
},
"account_create_reset": {
"category": "新增账号/重置",
"require_otp": True,
"examples": ["POST /api/admin/agents", "POST /api/admin/mfa/reset/{id}"],
"description": "新增坐席或重置 MFA",
},
}
class HighRiskGuard:
"""高危操作守卫服务。
负责 OTP 验证状态的读写,配套 require_high_risk_otp 依赖使用。
Attributes:
redis_client: Redis 异步客户端
ttl_seconds: OTP 验证有效期(默认 1800 秒 = 30 分钟)
"""
# Redis key 前缀 — 必须与 dependencies.MFA_VERIFIED_KEY_PREFIX 一致
KEY_PREFIX = "mfa:verified:"
# 默认 30 分钟 TTL — 必须与 dependencies.MFA_VERIFIED_TTL_SECONDS 一致
DEFAULT_TTL_SECONDS = 30 * 60
def __init__(
self,
redis_client: aioredis.Redis,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
):
"""初始化高危操作守卫。
Args:
redis_client: Redis 异步客户端
ttl_seconds: OTP 验证有效期(秒),默认 30 分钟
"""
self.redis = redis_client
self.ttl_seconds = ttl_seconds
def _key(self, employee_id: str) -> str:
"""构造 Redis key。
Args:
employee_id: 企微 UserID
Returns:
str: Redis key,如 mfa:verified:admin001
"""
return f"{self.KEY_PREFIX}{employee_id}"
async def mark_verified(
self,
employee_id: str,
method: str = "totp",
) -> bool:
"""标记管理员已通过 OTP 验证。
由 mfa.py 在 /api/mfa/verify 成功后调用。
Args:
employee_id: 企微 UserID
method: 验证方式,"totp""sms_backup"
Returns:
bool: 是否成功写入
"""
# value 用 JSON 存验证方式和时间,审计用
value = json.dumps(
{
"method": method,
"verified_at": datetime.now().isoformat(),
},
ensure_ascii=False,
)
try:
await self.redis.setex(
self._key(employee_id),
self.ttl_seconds,
value,
)
logger.info(
f"管理员 {employee_id} OTP 验证通过: method={method}, "
f"ttl={self.ttl_seconds}s"
)
return True
except Exception as e:
logger.error(f"写入 OTP verified key 失败: {e}")
return False
async def is_verified(self, employee_id: str) -> bool:
"""检查管理员是否在有效期内通过过 OTP。
由 require_high_risk_otp 依赖调用。
Args:
employee_id: 企微 UserID
Returns:
bool: 是否已通过 OTP 验证
"""
try:
value = await self.redis.get(self._key(employee_id))
# 空字符串 / None / 空 bytes 全部算"未通过"
if not value:
return False
return True
except Exception as e:
logger.error(f"读取 OTP verified key 失败: {e}")
# Redis 故障时保守放行?不,安全优先,默认不通过
return False
async def get_verification_info(
self,
employee_id: str,
) -> Optional[Dict]:
"""获取管理员 OTP 验证详情(含方式和时间)。
用于审计/前端展示"上次验证时间"
Args:
employee_id: 企微 UserID
Returns:
Optional[Dict]: 验证信息 dict,未验证返回 None
示例: {"method": "totp", "verified_at": "2026-06-21T15:30:00"}
"""
try:
value = await self.redis.get(self._key(employee_id))
if not value:
return None
if isinstance(value, bytes):
value = value.decode("utf-8")
return json.loads(value)
except Exception as e:
logger.error(f"解析 OTP verified info 失败: {e}")
return None
async def revoke(self, employee_id: str) -> bool:
"""撤销管理员 OTP 验证(强制重新验证)。
场景:安全事件触发 / 管理员主动撤销 / 登出时清理。
Args:
employee_id: 企微 UserID
Returns:
bool: 是否成功撤销(key 不存在也算成功)
"""
try:
deleted = await self.redis.delete(self._key(employee_id))
logger.info(
f"管理员 {employee_id} OTP 验证已撤销: deleted={deleted}"
)
return True
except Exception as e:
logger.error(f"撤销 OTP verified key 失败: {e}")
return False
async def refresh_ttl(self, employee_id: str) -> bool:
"""刷新 OTP 验证的 TTL(滑动窗口)。
每次高危操作通过守卫后调用,延长 30 分钟有效期。
已在 dependencies.require_high_risk_otp 内联调用,这里冗余暴露给 service 层。
Args:
employee_id: 企微 UserID
Returns:
bool: 是否刷新成功
"""
try:
# 只有 key 存在时才刷新 TTL,防止误创建空 key
value = await self.redis.get(self._key(employee_id))
if not value:
return False
await self.redis.expire(self._key(employee_id), self.ttl_seconds)
return True
except Exception as e:
logger.error(f"刷新 OTP verified TTL 失败: {e}")
return False
@staticmethod
def get_whitelist() -> Dict[str, Dict]:
"""获取 5 类高危操作白名单。
静态方法,供前端文档化展示"哪些操作需要 OTP"
Returns:
Dict[str, Dict]: 白名单字典
"""
return HIGH_RISK_OPERATIONS_WHITELIST.copy()
@staticmethod
def is_valid_category(category: str) -> bool:
"""检查 category 是否在 5 类白名单内。
Args:
category: 类别标识
Returns:
bool: 是否合法
"""
return category in HIGH_RISK_OPERATIONS_WHITELIST
@staticmethod
def list_categories() -> List[str]:
"""列出全部 5 类高危操作标识。
Returns:
List[str]: category 列表
"""
return list(HIGH_RISK_OPERATIONS_WHITELIST.keys())
# -----------------------------------------------------------------------------
# 工厂函数:方便在非 FastAPI DI 场景使用
# -----------------------------------------------------------------------------
def create_high_risk_guard(redis_client: aioredis.Redis) -> HighRiskGuard:
"""创建 HighRiskGuard 实例。
Args:
redis_client: Redis 异步客户端
Returns:
HighRiskGuard: 守卫服务实例
"""
return HighRiskGuard(redis_client)
+179
View File
@@ -0,0 +1,179 @@
# =============================================================================
# 企微IT智能服务台 — MFA(TOTP)服务封装
# =============================================================================
# 说明:把 pyotp + qrcode 的使用集中到 service 层,API 层只关心业务流程
# 设计要点:
# 1. secret 生成/校验/二维码生成 — 全部静态方法,无状态
# 2. valid_window=1 允许 ±30s 容忍(防用户手机秒数漂移)
# 3. Redis 验证标记独立 key(与 otp_secret 共存,不冲突)
# key 格式: mfa:verified:{employee_id}, TTL 1800s(30 分钟复用)
# 4. backup codes 在决策阶段已废止(otm-secondary-auth.md),所以本服务
# 不实现 backup code 逻辑,丢手机场景走 admin reset
# =============================================================================
import base64
import io
import logging
from typing import Tuple
import pyotp
import qrcode
import redis.asyncio as aioredis
logger = logging.getLogger(__name__)
# MFA 验证状态在 Redis 里的存活时间(秒)
# 跟 otm-secondary-auth.md 决策一致:30 分钟复用窗口
MFA_VERIFIED_TTL_SECONDS = 1800
class MFAService:
"""MFA TOTP 服务 — 封装 pyotp 二维码生成与验证。
所有方法都是纯函数/静态方法,无内部状态。
Redis 由调用方注入,便于测试时 mock。
"""
# --------------------------------------------------------------------------
# Secret 生成
# --------------------------------------------------------------------------
@staticmethod
def generate_secret() -> str:
"""生成新的 TOTP 共享密钥。
Returns:
str: 32 字符 base32 编码的随机密钥
"""
return pyotp.random_base32()
# --------------------------------------------------------------------------
# 二维码生成
# --------------------------------------------------------------------------
@staticmethod
def build_provisioning_uri(secret: str, employee_id: str) -> str:
"""构造 otpauth:// URI,供 Authenticator 扫码识别。
Args:
secret: TOTP 共享密钥(base32)
employee_id: 用户标识(扫码后显示的账户名)
Returns:
str: otpauth://totp/... 格式 URI
"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(
name=employee_id,
issuer_name="企微IT智能服务台",
)
@staticmethod
def render_qrcode_base64(otpauth_url: str) -> str:
"""把 otpauth URI 渲染成 PNG 并返回 base64 字符串。
Args:
otpauth_url: otpauth:// URI
Returns:
str: PNG 的 base64(不含 data:image/png;base64, 前缀,
由前端自行拼接或直接用 data URL)
"""
img = qrcode.make(otpauth_url)
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
# --------------------------------------------------------------------------
# 验证码校验
# --------------------------------------------------------------------------
@staticmethod
def verify_code(secret: str, otp_code: str, valid_window: int = 1) -> bool:
"""校验用户输入的 6 位 OTP 码。
Args:
secret: TOTP 共享密钥(base32)
otp_code: 用户输入的 6 位码
valid_window: 时间容忍窗口(1 = 允许当前 ±30s)
Returns:
bool: True=验证通过, False=验证失败
"""
if not secret or not otp_code:
return False
try:
totp = pyotp.TOTP(secret)
return bool(totp.verify(otp_code, valid_window=valid_window))
except Exception as e:
# 任意异常(secret 格式错、码非数字等)都视为验证失败
logger.warning(f"MFA verify_code 异常: {e}")
return False
# --------------------------------------------------------------------------
# 高层便捷方法:启动绑定
# --------------------------------------------------------------------------
@staticmethod
def start_binding(employee_id: str) -> Tuple[str, str, str]:
"""一次性生成绑定所需的全部数据(secret + URI + QR)。
Args:
employee_id: 用户标识
Returns:
Tuple[str, str, str]: (secret, otpauth_url, qr_code_base64)
"""
secret = MFAService.generate_secret()
otpauth_url = MFAService.build_provisioning_uri(secret, employee_id)
qr_base64 = MFAService.render_qrcode_base64(otpauth_url)
return secret, otpauth_url, qr_base64
# --------------------------------------------------------------------------
# Redis 验证标记(30 分钟复用)
# --------------------------------------------------------------------------
@staticmethod
async def mark_verified(
redis: aioredis.Redis, employee_id: str, ttl_seconds: int = MFA_VERIFIED_TTL_SECONDS
) -> None:
"""在 Redis 里写"已验证"标记,后续敏感操作直接查这个 key。
Args:
redis: Redis 客户端
employee_id: 用户标识
ttl_seconds: 存活秒数,默认 1800s
"""
key = f"mfa:verified:{employee_id}"
await redis.set(key, "1", ex=ttl_seconds)
@staticmethod
async def is_verified(redis: aioredis.Redis, employee_id: str) -> bool:
"""检查用户当前是否有未过期的 MFA 验证标记。
Args:
redis: Redis 客户端
employee_id: 用户标识
Returns:
bool: True=在 30 分钟复用窗口内
"""
key = f"mfa:verified:{employee_id}"
return bool(await redis.exists(key))
@staticmethod
async def clear_verified(redis: aioredis.Redis, employee_id: str) -> None:
"""清除 Redis 验证标记(关闭 MFA 时调用)。"""
key = f"mfa:verified:{employee_id}"
await redis.delete(key)
@staticmethod
async def get_verified_ttl(redis: aioredis.Redis, employee_id: str) -> int:
"""获取 Redis 验证标记剩余秒数(测试用,生产路径用不到)。
Args:
redis: Redis 客户端
employee_id: 用户标识
Returns:
int: 剩余秒数(无 key 返回 -2)
"""
key = f"mfa:verified:{employee_id}"
ttl = await redis.ttl(key)
return int(ttl) if ttl is not None else -2
+487
View File
@@ -0,0 +1,487 @@
# =============================================================================
# 企微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,
}