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
+95 -11
View File
@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.services.avatar_service import clean_avatar_url
from app.services.wecom_service import WecomService
from app.utils.response import (
AppException,
@@ -260,6 +261,66 @@ class SessionService:
return conversation
# --------------------------------------------------------------------------
# 自动分配空闲坐席(排队系统核心)
# --------------------------------------------------------------------------
async def auto_assign_agent(
self, conversation_id: UUID
) -> Optional[Agent]:
"""自动分配空闲坐席。
查找当前负载最低的空闲坐席进行分配。
流程:
1. 查询所有状态为online且未满负荷的坐席
2. 按current_load升序排列(负载最低的优先)
3. 分配给负载最低的坐席
4. 更新会话状态为serving
Args:
conversation_id: 会话ID
Returns:
Agent: 分配成功的坐席对象;None表示无空闲坐席
"""
from sqlalchemy import select
from app.models.agent import Agent
# 1. 查询空闲坐席(在线且未满负荷)
stmt = select(Agent).where(
Agent.status == "online",
Agent.current_load < Agent.max_load
).order_by(Agent.current_load.asc())
result = await self.db.execute(stmt)
agents = result.scalars().all()
if not agents:
logger.info(f"无空闲坐席: conv_id={conversation_id}")
return None
# 2. 选择负载最低的坐席
agent = agents[0]
# 3. 分配坐席
conversation = await self._get_conversation(conversation_id)
conversation.status = "serving"
conversation.assigned_agent_id = agent.user_id
conversation.updated_at = datetime.now()
self.db.add(conversation)
# 4. 更新坐席负载
agent.current_load += 1
self.db.add(agent)
await self.db.flush()
logger.info(
f"自动分配坐席: conv_id={conversation_id}, agent={agent.user_id}, load={agent.current_load}/{agent.max_load}"
)
return agent
# --------------------------------------------------------------------------
# 结单
# --------------------------------------------------------------------------
@@ -822,8 +883,8 @@ class SessionService:
# 邀请功能(P0-09~P0-11):坐席邀请员工/部门加入会话
# ======================================================================
# 头像缓存 TTL7天
AVATAR_CACHE_TTL = 7 * 24 * 60 * 60
# 头像缓存 TTL1天(要求 B:原 7 天过长,长期缓存过期/失效 URL 导致前端裂图)
AVATAR_CACHE_TTL = 1 * 24 * 60 * 60
async def _get_employee_avatar(self, employee_id: str) -> str:
"""获取员工头像URL(带Redis缓存)。
@@ -831,7 +892,11 @@ class SessionService:
优先级:
1. Redis 缓存(最快)
2. employees 表
3. 企微API(获取后存入Redis缓存)
3. 企微API(获取后存入Redis缓存 + 回写 DB 稳定 URL
头像 URL 稳定性处理(要求 B):
- 缓存 TTL 由 7 天缩短为 1 天,避免长期缓存过期/失效 URL。
- 返回前清理企微头像 URL 的多余查询参数,保留稳定部分,降低 404 概率。
Args:
employee_id: 企微员工UserID
@@ -846,14 +911,16 @@ class SessionService:
try:
cached_avatar = await self.redis_client.get(cache_key)
if cached_avatar:
logger.debug(f"从Redis缓存获取头像: employee_id={employee_id}")
return cached_avatar.decode("utf-8") if isinstance(cached_avatar, bytes) else cached_avatar
# 兼容历史缓存中可能带查询参数,统一清理后返回
return clean_avatar_url(
cached_avatar.decode("utf-8") if isinstance(cached_avatar, bytes) else cached_avatar
)
except Exception as e:
logger.warning(f"从Redis获取头像缓存失败: employee_id={employee_id}, error={e}")
# 2. 从 employees 表获取(需要匹配 corp_id
from app.models.employee import Employee
from app.core.config import settings
from app.config import settings
result = await self.db.execute(
select(Employee.avatar).where(
Employee.employee_id == employee_id,
@@ -862,14 +929,15 @@ class SessionService:
)
row = result.first()
if row and row[0]:
logger.info(f"从employees表获取头像: employee_id={employee_id}, avatar={row[0][:50]}...")
# 存入 Redis 缓存
cleaned = clean_avatar_url(row[0])
logger.info(f"从employees表获取头像: employee_id={employee_id}, avatar={cleaned[:50]}...")
# 存入 Redis 缓存(已清理的稳定 URL)
if self.redis_client:
try:
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, row[0])
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, cleaned)
except Exception as e:
logger.warning(f"存入Redis头像缓存失败: employee_id={employee_id}, error={e}")
return row[0]
return cleaned
else:
logger.info(f"employees表无头像记录: employee_id={employee_id}")
@@ -878,7 +946,8 @@ class SessionService:
if self.wecom_service:
try:
user_info = await self.wecom_service.get_user_info(employee_id)
avatar = user_info.get("avatar", "")
raw = user_info.get("avatar", "")
avatar = clean_avatar_url(raw)
logger.info(f"企微API返回头像: employee_id={employee_id}, avatar={'有值(' + str(len(avatar)) + '字符)' if avatar else ''}")
# 存入 Redis 缓存(即使为空也缓存,避免频繁请求API)
if self.redis_client and avatar:
@@ -886,6 +955,21 @@ class SessionService:
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, avatar)
except Exception as e:
logger.warning(f"存入Redis头像缓存失败: employee_id={employee_id}, error={e}")
# 回写 DB:把稳定 URL 落库,下次直接从 DB 读取,减少企微 API 调用
if avatar:
try:
from sqlalchemy import update as sa_update
await self.db.execute(
sa_update(Employee)
.where(
Employee.employee_id == employee_id,
Employee.corp_id == settings.wecom_corp_id,
)
.values(avatar=avatar, avatar_updated_at=datetime.utcnow())
)
await self.db.commit()
except Exception as e:
logger.warning(f"回写员工头像到DB失败: employee_id={employee_id}, error={e}")
except Exception as e:
logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}")