Files
wecom_it_smart_desk/backend/app/api/auth.py
T
Simon bea288e414 feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
2026-07-11 23:13:10 +08:00

522 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 统一认证 API
# =============================================================================
# 说明:统一认证入口,整合现有的扫码登录和企微SSO
# 三端认证重构:取消OTP/账号密码登录,统一为企微扫码认证
#
# API 端点列表:
# GET /api/auth/qrcode — 获取扫码登录二维码
# GET /api/auth/scan/status — 轮询扫码状态
# GET /api/auth/oauth2/callback — OAuth2回调处理
# POST /api/auth/bind — 账号绑定(互联企业)
# POST /api/auth/verify — 验证Token
# POST /api/auth/logout — 登出
# GET /api/auth/me — 获取当前用户信息
# POST /api/auth/switch-role — 切换角色
# =============================================================================
import logging
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import UserInfo, get_current_user, get_redis
from app.schemas.auth import (
BindAccountRequest,
CurrentUserResponse,
LogoutResponse,
SwitchRoleRequest,
SwitchRoleResponse,
TokenVerifyRequest,
TokenVerifyResponse,
UnifiedAuthResponse,
)
from app.services.auth_service import AuthService
from app.services.token_service import TokenService, TOKEN_TTL_SECONDS
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter(prefix="/auth", tags=["统一认证"])
# Token 黑名单 Key 前缀
TOKEN_BLACKLIST_PREFIX = "token:blacklist:"
def _get_auth_service(
redis_client: aioredis.Redis,
db: AsyncSession = Depends(get_db),
) -> AuthService:
"""工厂函数:构造统一认证服务。
Args:
redis_client: Redis 客户端
db: 数据库会话
Returns:
AuthService: 统一认证服务实例
"""
return AuthService(redis_client, db)
def _get_client_info(request: Request) -> tuple:
"""获取客户端信息。
Args:
request: 请求对象
Returns:
tuple: (ip_address, user_agent)
"""
# 获取客户端 IP
ip_address = request.client.host if request.client else None
# 尝试从代理获取真实 IP
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip()
# 获取 User-Agent
user_agent = request.headers.get("User-Agent", "")
return ip_address, user_agent
# --------------------------------------------------------------------------
# GET /api/auth/qrcode — 获取扫码登录二维码
# --------------------------------------------------------------------------
@router.get("/qrcode", response_model=None)
async def get_qrcode(
redis_client: aioredis.Redis = Depends(get_redis),
):
"""获取扫码登录二维码。
无需鉴权(用户尚未登录)。
返回 ticket + 企微 OAuth2 授权 URL,前端渲染二维码。
Returns:
Dict: 统一响应格式,data 字段包含 qrcode 信息
"""
try:
# 复用现有的扫码服务
from app.services.qrcode_service import QrcodeService
service = QrcodeService(redis_client)
result = await service.create_ticket()
return success_response(data={
"ticket": result["ticket"],
"qrcode_url": result["qrcode_url"],
"qrcode_png_base64": result["qrcode_png_base64"],
"expires_in": result["expires_in"],
"expires_at": result["expires_at"].isoformat(),
})
except Exception as e:
logger.error(f"获取扫码二维码异常: {e}", exc_info=True)
raise AppException(1005, f"获取扫码二维码失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /api/auth/scan/status — 轮询扫码状态
# --------------------------------------------------------------------------
@router.get("/scan/status", response_model=None)
async def get_scan_status(
ticket: str = Query(..., description="扫码登录票据"),
redis_client: aioredis.Redis = Depends(get_redis),
):
"""轮询扫码状态。
无需鉴权(浏览器未登录态访问)。
状态机:
- waiting: ticket 有效,等待扫码
- scanned: 已扫码,等待 confirm
- confirmed: 已确认,返回 token
- expired: ticket 过期/不存在
Args:
ticket: 扫码登录票据
Returns:
Dict: 统一响应格式,data 字段包含状态信息
"""
try:
# 复用现有的扫码服务
from app.services.qrcode_service import QrcodeService
service = QrcodeService(redis_client)
result = await service.get_poll_state(ticket)
return success_response(data={
"status": result["status"],
"employee_id": result.get("employee_id"),
"name": result.get("name"),
"token": result.get("token"),
})
except Exception as e:
logger.error(f"轮询扫码状态异常: ticket={ticket[:8]}..., error={e}", exc_info=True)
raise AppException(1005, f"轮询扫码状态失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /api/auth/oauth2/url — 获取 OAuth2 授权 URL
# --------------------------------------------------------------------------
@router.get("/oauth2/url", response_model=None)
async def get_oauth2_url(
redirect_uri: str = Query(..., description="授权成功后的回调地址"),
):
"""获取企微 OAuth2 授权 URL。
前端在企微内打开时调用此接口,获取授权 URL 后跳转到企微授权页。
授权成功后企微会携带 code 回调到 redirect_uri。
Args:
redirect_uri: 授权成功后的回调地址(三端不同:/h5/、/itadmin/、/itagent/
Returns:
Dict: 统一响应格式,data 字段包含 authorize_url
"""
import urllib.parse
# 构建企微 OAuth2 授权 URL
params = {
"appid": settings.wecom_corp_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "snsapi_base",
"state": "auth_login",
}
if getattr(settings, "wecom_agent_id", None):
params["agentid"] = str(settings.wecom_agent_id)
query = urllib.parse.urlencode(params)
authorize_url = f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
logger.info(f"生成统一认证OAuth授权URL: redirect_uri={redirect_uri}")
return success_response(data={
"authorize_url": authorize_url,
"redirect_uri": redirect_uri,
})
# --------------------------------------------------------------------------
# GET /api/auth/oauth2/callback — OAuth2回调处理
# --------------------------------------------------------------------------
@router.get("/oauth2/callback", response_model=None)
async def oauth2_callback(
request: Request,
code: Optional[str] = Query(None, description="企微 OAuth2 授权 code"),
state: Optional[str] = Query(None, description="防 CSRF state"),
errcode: Optional[int] = Query(None, description="企微 OAuth 错误码"),
errmsg: Optional[str] = Query(None, description="企微 OAuth 错误信息"),
redis_client: aioredis.Redis = Depends(get_redis),
db: AsyncSession = Depends(get_db),
):
"""企微 OAuth2 回调处理。
用 code 换 userid → 查 role → 生成 token → 跳转到目标页面。
实际上是复用现有的 auth_wecom_sso 逻辑。
Args:
code: 企微 OAuth2 授权 code
state: 防 CSRF state
errcode: 企微 OAuth 错误码
errmsg: 企微 OAuth 错误信息
Returns:
RedirectResponse: 跳转到目标页面
"""
from fastapi.responses import RedirectResponse
# 复用 auth_wecom_sso 的逻辑
from app.api.auth_wecom_sso import sso_callback
# 调用现有的 SSO callback
return await sso_callback(
request=request,
code=code,
state=state,
errcode=errcode,
errmsg=errmsg,
next=None,
redis_client=redis_client,
db=db,
)
# --------------------------------------------------------------------------
# POST /api/auth/verify — 验证 Token
# --------------------------------------------------------------------------
@router.post("/verify", response_model=None)
async def verify_token(
body: TokenVerifyRequest,
redis_client: aioredis.Redis = Depends(get_redis),
):
"""验证 Token 有效性。
Args:
body: Token 验证请求
Returns:
Dict: 统一响应格式,data 字段包含验证结果
"""
try:
auth_service = _get_auth_service(redis_client)
user_info = await auth_service.verify_token(body.token)
if not user_info:
return success_response(data=TokenVerifyResponse(
valid=False,
))
# 获取 Token 剩余有效期
import hashlib
token_hash = hashlib.sha256(body.token.encode()).hexdigest()
key = f"user:token:{token_hash}"
ttl = await redis_client.ttl(key)
if ttl < 0:
ttl = TOKEN_TTL_SECONDS
return success_response(data=TokenVerifyResponse(
valid=True,
employee_id=user_info.get("employee_id"),
name=user_info.get("name"),
roles=user_info.get("roles", []),
current_role=user_info.get("current_role"),
login_source=user_info.get("login_source"),
expires_in=ttl,
))
except Exception as e:
logger.error(f"验证 Token 异常: {e}", exc_info=True)
raise AppException(1005, f"验证 Token 失败: {str(e)}")
# --------------------------------------------------------------------------
# POST /api/auth/logout — 登出
# --------------------------------------------------------------------------
@router.post("/logout", response_model=None)
async def logout(
request: Request,
current_user: UserInfo = Depends(get_current_user),
redis_client: aioredis.Redis = Depends(get_redis),
):
"""登出:将 Token 加入黑名单并清除 Redis 中的 Token。
需要鉴权:已登录用户才能登出。
Args:
current_user: 当前用户信息
Returns:
Dict: 统一响应格式
"""
try:
# 从请求头获取 Token
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
# 将 Token 加入黑名单
import hashlib
token_hash = hashlib.sha256(token.encode()).hexdigest()
key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}"
await redis_client.setex(key, TOKEN_TTL_SECONDS, "1")
# 清除 Redis 中的 Token
token_service = TokenService(redis_client)
await token_service.invalidate_token(token)
logger.info(f"用户登出: employee_id={current_user.employee_id}")
return success_response(data=LogoutResponse(
success=True,
message="登出成功",
))
except Exception as e:
logger.error(f"登出异常: {e}", exc_info=True)
raise AppException(1005, f"登出失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /api/auth/me — 获取当前用户信息
# --------------------------------------------------------------------------
@router.get("/me", response_model=None)
async def get_current_user_info(
current_user: UserInfo = Depends(get_current_user),
):
"""获取当前用户信息。
需要鉴权:已登录用户才能调用。
Args:
current_user: 当前用户信息
Returns:
Dict: 统一响应格式,data 字段包含用户信息
"""
return success_response(data=CurrentUserResponse(
employee_id=current_user.employee_id,
name=current_user.name,
avatar=current_user.avatar,
department=current_user.department,
roles=current_user.roles,
current_role=current_user.current_role,
login_source=current_user.login_source,
))
# --------------------------------------------------------------------------
# POST /api/auth/switch-role — 切换角色
# --------------------------------------------------------------------------
@router.post("/switch-role", response_model=None)
async def switch_role(
body: SwitchRoleRequest,
current_user: UserInfo = Depends(get_current_user),
redis_client: aioredis.Redis = Depends(get_redis),
request: Request = None,
):
"""切换当前角色。
需要鉴权:已登录用户才能调用。
Args:
body: 角色切换请求
current_user: 当前用户信息
Returns:
Dict: 统一响应格式
"""
try:
# 从请求头获取 Token
token = None
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
raise AppException(1001, "无法获取 Token")
auth_service = _get_auth_service(redis_client)
success = await auth_service.switch_role(token, body.role)
if not success:
raise AppException(1003, f"切换角色失败:用户没有 {body.role} 角色")
# 获取更新后的用户信息
user_info = await auth_service.get_current_user(token)
logger.info(
f"用户切换角色: employee_id={current_user.employee_id}, "
f"new_role={body.role}"
)
return success_response(data=SwitchRoleResponse(
success=True,
current_role=body.role,
message="角色切换成功",
))
except AppException:
raise
except Exception as e:
logger.error(f"切换角色异常: {e}", exc_info=True)
raise AppException(1005, f"切换角色失败: {str(e)}")
# --------------------------------------------------------------------------
# POST /api/auth/bind — 账号绑定(互联企业)
# --------------------------------------------------------------------------
@router.post("/bind", response_model=None)
async def bind_account(
body: BindAccountRequest,
request: Request,
redis_client: aioredis.Redis = Depends(get_redis),
db: AsyncSession = Depends(get_db),
):
"""账号绑定(互联企业)。
用于互联企业用户绑定已有账号。
Args:
body: 账号绑定请求(含 employee_id, corp_id 等)
request: 请求对象(用于获取客户端信息)
Returns:
Dict: 统一响应格式
"""
try:
from sqlalchemy import select
from app.models.employee import Employee
# corp_id 默认使用系统配置的主企业ID
corp_id = body.corp_id or settings.wecom_corp_id
employee_id = body.employee_id
# 查询员工信息
stmt = select(Employee).where(
Employee.corp_id == corp_id,
Employee.employee_id == employee_id,
)
result = await db.execute(stmt)
employee = result.scalar_one_or_none()
if not employee:
raise AppException(1003, "员工信息不存在,请先通过企微认证")
# 查询角色
from app.models.role import Role
from app.models.user_role import UserRole
role_stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == employee_id)
)
role_result = await db.execute(role_stmt)
roles = role_result.scalars().all()
if not roles:
# 没有绑定角色:跳"无权限"页
raise AppException(1003, "该员工没有绑定角色,请联系管理员")
# 选择最高权限角色
role_priority = {"admin": 3, "agent": 2, "user": 1}
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
role_name = best_role.name
role_list = [r.name for r in roles]
# 获取客户端信息
ip_address, user_agent = _get_client_info(request)
# 创建 Token 并记录登录日志
auth_service = _get_auth_service(redis_client, db)
result = await auth_service.create_token_and_login(
employee_id=employee_id,
name=employee.name,
roles=role_list,
corp_id=corp_id,
login_method="bind",
login_source="h5",
department=employee.department,
avatar=employee.avatar,
ip_address=ip_address,
user_agent=user_agent,
)
return success_response(data=UnifiedAuthResponse(**result))
except AppException:
raise
except Exception as e:
logger.error(f"账号绑定异常: {e}", exc_info=True)
raise AppException(1005, f"账号绑定失败: {str(e)}")