WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+507 -16
View File
@@ -44,6 +44,7 @@ limiter = Limiter(key_func=get_remote_address)
from app.config import settings
from app.database import get_db
from app.utils.env_gating import is_production
from app.dependencies import dep_redis, dep_wecom_service, dep_ai_handler
from app.models.approval_link import ApprovalLink
from app.models.conversation import Conversation
@@ -83,18 +84,18 @@ _WEWORK_UA_RE = re.compile(r"wxwork", re.IGNORECASE)
def _require_wework_ua(request: Request) -> None:
"""校验请求 User-Agent 是否来自企微 WebView。
生产环境下,非企微环境的 OAuth2 请求直接拒绝。
本地开发(localhost / 127.0.0.1)跳过检测,方便调试
生产环境强制校验(env_gating.is_production()):
非企微环境的 OAuth2 请求直接拒绝
本地开发 / dev / test 环境跳过检测,方便调试。
Args:
request: FastAPI Request 对象,用于读取 User-Agent 和 Host
request: FastAPI Request 对象,用于读取 User-Agent
Raises:
AppException: 非企微环境时抛出 403 错误
AppException: 非企微环境且处于生产环境时抛出 4003 错误
"""
# 本地开发跳过检测
host = request.headers.get("host", "")
if host.startswith("localhost") or host.startswith("127.0.0.1"):
# 仅生产环境强制校验;非生产环境(dev/test/本地)一律放行
if not is_production():
return
ua = request.headers.get("user-agent", "")
@@ -253,11 +254,11 @@ async def get_oauth_authorize_url(
elif request_host:
# 从 Host 头构造回调地址(支持 http 和 https)
scheme = "https" # 企微H5应用通常使用 https
encoded_redirect = quote(f"{scheme}://{request_host}/itportal/", safe="")
encoded_redirect = quote(f"{scheme}://{request_host}/itdesk/", safe="")
else:
# 最终降级:使用配置中的 CORS 源地址
default_origin = settings.cors_origins_list[0] if settings.cors_origins_list else "https://localhost"
encoded_redirect = quote(f"{default_origin}/itportal/", safe="")
encoded_redirect = quote(f"{default_origin}/itdesk/", safe="")
# 构造企微OAuth2静默授权URLsnsapi_base:用户无感知)
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com
@@ -362,11 +363,14 @@ async def oauth_callback(
employee.name = employee_name
employee.department = department
employee.position = position
# 【FE-UA-005 优化】每次登录强制更新头像URL,确保获取最新头像
# 【FE-UA-005 优化】每次登录强制更新头像URL + 清缓存(统一走 avatar_service
# 头像更新失败不阻塞登录(sync_employee_avatar 内部已容错)
if avatar:
employee.avatar = avatar
employee.avatar_updated_at = datetime.utcnow()
logger.info(f"更新员工头像: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
try:
from app.services.avatar_service import sync_employee_avatar
await sync_employee_avatar(db, redis_client, employee_id, avatar)
except Exception as e:
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
else:
# 创建新记录
employee = Employee(
@@ -437,6 +441,137 @@ async def oauth_callback(
raise AppException(2007, f"OAuth2授权失败: {e}")
# --------------------------------------------------------------------------
# GET /api/h5/oauth/sns-callback — 企微 OAuth2 静默授权回调(302 重定向版)
# --------------------------------------------------------------------------
@router.get("/h5/oauth/sns-callback")
async def oauth_sns_callback(
request: Request,
code: str = Query(..., description="企微 OAuth2 授权码"),
state: Optional[str] = Query(None, description="透传参数(保留兼容,未使用)"),
db: AsyncSession = Depends(get_db),
redis_client: Optional[aioredis.Redis] = Depends(dep_redis),
wecom_service: WecomService = Depends(dep_wecom_service),
):
"""企微 OAuth2 静默授权回调(snsapi_base → 302 带 ?token=)。
适用于 snsapi_base 静默授权:企微回调到此端点并携带 code,
后端用 code 换取员工身份 → 生成 Bearer Token → 302 重定向到
H5 前端页面,并在 URL 上附带 ?token=,供前端镜像到 localStorage。
仅生产环境强制 UA 校验(与 _require_wework_ua 一致,使用 env_gating)。
Args:
code: 企微授权码
state: 透传参数(未使用,保留兼容)
db: 数据库会话
redis_client: 共享 Redis 客户端(DI 注入)
wecom_service: 共享企微服务(DI 注入)
Returns:
RedirectResponse -> {scheme}://{host}/itdesk/?token={token}
"""
# 仅生产环境强制 UA 校验
if is_production():
ua = request.headers.get("user-agent", "")
if not _WEWORK_UA_RE.search(ua):
raise AppException(4003, "请在企业微信中访问此服务")
# 1. 用 code 换取员工身份
user_info = await wecom_service.get_oauth_user_info(code)
employee_id = user_info.get("userid", "")
if not employee_id:
raise AppException(2007, "OAuth2授权失败:未获取到员工ID")
# 2. 获取员工详细信息(姓名、部门、岗位、头像)
employee_name = ""
department = ""
position = ""
avatar = ""
try:
detail = await wecom_service.get_user_info(employee_id)
employee_name = detail.get("name", "")
dept_ids = detail.get("department", [])
department = ",".join(str(d) for d in dept_ids) if dept_ids else ""
position = detail.get("position", "")
avatar = detail.get("avatar", "")
except Exception as e:
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
# 3. 落库 / 更新员工信息(含头像)
try:
from app.models.employee import Employee
stmt = select(Employee).where(
Employee.employee_id == employee_id,
Employee.corp_id == settings.wecom_corp_id,
)
result = await db.execute(stmt)
employee = result.scalars().first()
if employee:
employee.name = employee_name
employee.department = department
employee.position = position
if avatar:
try:
from app.services.avatar_service import sync_employee_avatar
await sync_employee_avatar(db, redis_client, employee_id, avatar)
except Exception as e:
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
else:
employee = Employee(
corp_id=settings.wecom_corp_id,
employee_id=employee_id,
name=employee_name,
department=department,
position=position,
avatar=avatar,
)
db.add(employee)
await db.commit()
except Exception as e:
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
# 4. 生成 Bearer Token 并写入 Redis
token = secrets.token_urlsafe(32)
if redis_client:
try:
await redis_client.setex(
f"employee:token:{token}",
EMPLOYEE_TOKEN_TTL_SECONDS,
employee_id,
)
except Exception as e:
logger.warning(f"Redis 写入失败(token 不会持久化): {e}")
employee_info_cache = {
"employee_id": employee_id,
"employee_name": employee_name,
"department": department,
"position": position,
"avatar": avatar,
}
try:
await redis_client.setex(
f"employee:info:{employee_id}",
EMPLOYEE_TOKEN_TTL_SECONDS,
json.dumps(employee_info_cache, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"员工信息缓存写入失败(不阻塞流程): {e}")
logger.info(f"OAuth2 sns-callback 授权成功: employee_id={employee_id}, name={employee_name}")
# 5. 302 重定向到 H5 前端页面,附带 ?token= 供前端镜像到 localStorage
from fastapi.responses import RedirectResponse
host = request.headers.get("host", "")
scheme = "https"
landing = "/itdesk/"
redirect_url = f"{scheme}://{host}{landing}?token={token}"
return RedirectResponse(url=redirect_url)
# --------------------------------------------------------------------------
# POST /api/h5/mock-login — Mock 登录(测试阶段,跳过 OAuth2)
# --------------------------------------------------------------------------
@@ -856,8 +991,75 @@ async def h5_send_message(
# --------------------------------------------------------------------------
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
# GET /api/h5/conversations/current/messages — 用户获取消息列表(历史消息)
# --------------------------------------------------------------------------
@router.get("/h5/conversations/current/messages")
async def h5_get_messages(
limit: int = Query(50, description="每页消息数量,默认50"),
before: Optional[str] = Query(None, description="获取此消息ID之前的消息(向上翻页)"),
employee_id: str = Depends(_get_current_employee),
db: AsyncSession = Depends(get_db),
):
"""H5 用户获取消息列表(历史消息)。
前端在进入会话或切换会话时调用,获取完整的消息历史记录。
支持分页向上翻页(通过 before 参数)。
Args:
limit: 每页消息数量(默认50)
before: 消息ID,获取此消息之前的消息(向上翻页)
employee_id: 员工企微 UserID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含消息列表和 has_more 标志
"""
# 查找当前会话
stmt = select(Conversation).where(
Conversation.employee_id == employee_id,
Conversation.status.in_(["ai_handling", "queued", "serving"]),
).order_by(Conversation.created_at.desc())
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
return success_response(data={"items": [], "has_more": False})
# 查询消息列表
msg_stmt = select(Message).where(
Message.conversation_id == conversation.id
).order_by(Message.created_at.desc()).limit(limit)
# 如果指定了 before,获取此消息之前的消息
if before:
try:
from uuid import UUID as UUIDType
UUIDType(before) # 仅校验格式
# 查询 before 消息的创建时间
before_stmt = select(Message.created_at).where(
Message.id == str(before)
)
before_result = await db.execute(before_stmt)
before_time = before_result.scalar_one_or_none()
if before_time:
msg_stmt = msg_stmt.where(Message.created_at < before_time)
except ValueError:
pass # 无效的UUID格式,忽略 before 参数
msg_result = await db.execute(msg_stmt)
messages = list(msg_result.scalars().all())
# 反转顺序(按时间正序返回)
messages.reverse()
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
# 判断是否还有更多:查询的消息数是否等于 limit
has_more = len(messages) == limit
return success_response(data={"items": items, "has_more": has_more})
# --------------------------------------------------------------------------
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
@@ -1019,18 +1221,307 @@ async def shake(
except Exception as e:
logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}")
# 5. 自动分配空闲坐席
from app.services.session_service import SessionService
from app.services.ws_manager import manager as ws_manager
from app.models.agent import Agent
# 5. 返回会话信息和话术
assigned_agent_id: Optional[str] = None
assign_result: str = "queued"
# 查找在线且未满负荷的坐席(按当前负载升序,取第一个)
stmt = select(Agent).where(
Agent.status == "online",
Agent.current_load < Agent.max_load
).order_by(Agent.current_load).limit(1)
result = await db.execute(stmt)
available_agent = result.scalars().first()
if available_agent:
# 找到空闲坐席,分配给该会话
try:
session_service = SessionService(db, wecom_service)
await session_service.assign_agent(conversation.id, available_agent.user_id)
assigned_agent_id = available_agent.user_id
assign_result = "assigned"
logger.info(f"自动分配坐席: conv_id={conversation.id}, agent={assigned_agent_id}")
except Exception as e:
logger.warning(f"自动分配坐席失败: {e}")
assign_result = "assign_failed"
else:
# 无空闲坐席,进入排队(会话状态保持 queued,由 AI 未命中时自动处理)
assign_result = "queued"
logger.info(f"无空闲坐席,会话进入排队: conv_id={conversation.id}")
# 6. 广播 new_conversation 事件通知所有坐席
try:
await ws_manager.broadcast({
"type": "new_conversation",
"data": {
"conversation_id": str(conversation.id),
"employee_id": employee_id,
"employee_name": employee_name or "未知用户",
"urgency_score": conversation.urgency_score,
"hand_raise": True,
"assigned_agent_id": assigned_agent_id,
"assign_result": assign_result,
}
})
except Exception as e:
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}")
# 7. 返回会话信息和话术
conv_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(
data={
"conversation": conv_data,
"funny_phrase": phrase,
"assign_result": assign_result,
"assigned_agent_id": assigned_agent_id,
}
)
# --------------------------------------------------------------------------
# POST /api/h5/conversations/current/call-agent — 摇人按钮触发转人工
# --------------------------------------------------------------------------
@router.post("/h5/conversations/current/call-agent")
async def call_agent(
body: ShakeRequest,
db: AsyncSession = Depends(get_db),
wecom_service: Optional[WecomService] = Depends(dep_wecom_service),
):
"""摇人按钮 - 呼叫坐席。
用户点击摇人按钮后,触发转人工流程:
1. 查找当前会话
2. 校验AI回复次数 >= 3(与shake一致)
3. 将会话状态改为 queued(排队中)
4. 尝试分配空闲坐席
5. 发送系统消息通知用户
6. 通过企微消息通知坐席
Args:
body: 呼叫坐席请求体(包含 employee_id 和 employee_name
db: 数据库会话
wecom_service: 共享企微服务(DI 注入)
Returns:
Dict: 包含会话信息和排队状态
"""
from app.services.session_service import SessionService
employee_id = body.employee_id
employee_name = body.employee_name
# 1. 查找当前活跃会话
stmt = select(Conversation).where(
Conversation.employee_id == employee_id,
Conversation.status.in_(["ai_handling", "queued", "serving"]),
).order_by(Conversation.created_at.desc())
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise AppException(
code=1003,
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
)
# 2. 前置校验:必须满足 AI 实质性回复 >= 3 次
if conversation.ai_substantive_reply_count < 3:
raise AppException(
code=1003,
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
)
# 更新员工姓名
if employee_name and not conversation.employee_name:
conversation.employee_name = employee_name
# 3. 将会话状态改为 queued(排队中)
conversation.status = "queued"
conversation.last_message_at = datetime.now()
conversation.updated_at = datetime.now()
# 设置紧急度加分
tags = dict(conversation.tags) if conversation.tags else {}
tags["user_called_agent"] = True # 标记用户主动呼叫
conversation.tags = tags
db.add(conversation)
await db.flush()
# 4. 尝试分配空闲坐席
session_service = SessionService(db)
assigned_agent = await session_service.auto_assign_agent(conversation.id)
# 5. 获取趣味话术
funny_phrase_service = FunnyPhraseService(db)
is_vip = conversation.is_vip
phrase = await funny_phrase_service.get_phrase("transfer", is_vip=is_vip)
# 6. 创建系统消息
system_content = phrase
if assigned_agent:
system_content = f"{phrase}\n\n为您服务的是:{assigned_agent.name}"
conversation.status = "serving"
conversation.assigned_agent_id = assigned_agent.user_id
system_msg = Message(
conversation_id=conversation.id,
sender_type="system",
sender_id="system",
sender_name="系统",
content=system_content,
msg_type="system",
is_read=True,
)
db.add(system_msg)
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService
if wecom_service:
try:
await wecom_service.send_text_message(employee_id, system_content)
except Exception as e:
logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
# 8. 如果分配了坐席,通知坐席有新会话
if assigned_agent and wecom_service:
try:
notify_phrase = f"新会话:{employee_name} 呼叫人工服务,请及时接单"
# 获取坐席的userid并发送通知(需要坐席绑定企微)
# 此处简化处理,仅记录日志
logger.info(f"分配坐席: agent_id={assigned_agent.id}, employee_id={employee_id}")
except Exception as e:
logger.warning(f"坐席通知失败: {e}")
await db.commit()
logger.info(f"呼叫坐席: employee_id={employee_id}, conv_id={conversation.id}, agent_id={assigned_agent.id if assigned_agent else 'None'}")
# 9. 返回结果
conv_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(
data={
"conversation": conv_data,
"status": conversation.status,
"queue_position": 1 if not assigned_agent else None,
"estimated_wait_seconds": 30 if not assigned_agent else 0,
}
)
# --------------------------------------------------------------------------
# GET /api/h5/conversations/current/queue-status — 查询排队状态
# --------------------------------------------------------------------------
@router.get("/h5/conversations/current/queue-status")
async def get_queue_status(
employee_id: str = Query(..., description="员工ID"),
db: AsyncSession = Depends(get_db),
):
"""查询当前排队状态。
返回当前会话的排队位置和预计等待时间。
Args:
employee_id: 员工ID
Returns:
Dict: 排队状态信息
"""
from sqlalchemy import select, func
from app.models.conversation import Conversation
# 1. 查找该员工的排队会话
stmt = select(Conversation).where(
Conversation.employee_id == employee_id,
Conversation.status == "queued",
).order_by(Conversation.created_at.asc())
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
# 不在排队中,可能是已分配或无会话
return success_response(data={
"in_queue": False,
"status": None,
"queue_position": None,
"estimated_wait_seconds": 0,
})
# 2. 计算排队位置(按创建时间排序)
count_stmt = select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.created_at < conversation.created_at,
)
count_result = await db.execute(count_stmt)
queue_position = count_result.scalar() or 0
# 3. 计算预计等待时间(基于平均处理时长5分钟)
estimated_wait_seconds = queue_position * 300 # 5分钟/人
return success_response(data={
"in_queue": True,
"status": conversation.status,
"queue_position": queue_position + 1,
"estimated_wait_seconds": estimated_wait_seconds,
"conversation_id": str(conversation.id),
})
# --------------------------------------------------------------------------
# POST /api/h5/conversations/current/cancel-queue — 取消排队
# --------------------------------------------------------------------------
@router.post("/h5/conversations/current/cancel-queue")
async def cancel_queue(
body: ShakeRequest,
db: AsyncSession = Depends(get_db),
):
"""取消排队。
用户主动取消排队,释放排队位置。
Args:
body: 包含 employee_id
Returns:
Dict: 操作结果
"""
employee_id = body.employee_id
# 1. 查找排队中的会话
stmt = select(Conversation).where(
Conversation.employee_id == employee_id,
Conversation.status == "queued",
)
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise AppException(code=1004, message="您当前不在排队中")
# 2. 将会话状态改回 ai_handling
conversation.status = "ai_handling"
conversation.updated_at = datetime.now()
# 移除用户主动呼叫标记
tags = dict(conversation.tags) if conversation.tags else {}
tags.pop("user_called_agent", None)
conversation.tags = tags
db.add(conversation)
await db.commit()
logger.info(f"取消排队: employee_id={employee_id}, conv_id={conversation.id}")
return success_response(data={
"message": "已取消排队,会话将继续由AI服务",
})
# --------------------------------------------------------------------------
# GET /api/h5/approval-links — 获取审批流程链接
# --------------------------------------------------------------------------