docs: 移动蓝绿部署指南到 troubleshooting 目录

This commit is contained in:
Simon
2026-07-05 17:03:36 +08:00
parent ab90db3d3d
commit ca7c6d937a
91 changed files with 4841 additions and 406 deletions
@@ -0,0 +1,31 @@
"""add employee avatar_updated_at field for avatar refresh tracking
Revision ID: 042_add_employee_avatar_updated_at
Revises: 041_message_server_timestamp
Create Date: 2026-07-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '042_add_employee_avatar_updated_at'
down_revision: Union[str, None] = '041_message_server_timestamp'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add avatar_updated_at field to employees table
# This field tracks when the avatar was last updated from WeCom API
op.add_column(
'employees',
sa.Column('avatar_updated_at', sa.DateTime(), nullable=True, comment='头像最后更新时间')
)
def downgrade() -> None:
op.drop_column('employees', 'avatar_updated_at')
+161 -3
View File
@@ -257,9 +257,9 @@ async def agent_login(
await db.flush()
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. MFA 二次验证(admin 角色且已绑定 MFA
# v0.7.1: 用 mfa_secret/mfa_enabled 替代旧 otp_secret/otp_enabled
if agent.role == "admin" and agent.mfa_enabled:
# 2. MFA 二次验证(已绑定 MFA 的坐席/管理员
# v1.5: 坐席和管理员都需要 OTP 验证
if agent.mfa_enabled:
if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记
return success_response(data={
@@ -593,3 +593,161 @@ async def update_agent_password(
except Exception as e:
logger.error(f"密码更新异常: {e}", exc_info=True)
raise AppException(1014, f"密码更新失败: {str(e)}")
# ============================================================================
# 企微 OAuth2 一键登录(坐席端)
# ============================================================================
import urllib.parse
import secrets as secrets_module
def _build_agent_oauth_url(redirect_uri: str) -> str:
"""构建坐席端企微OAuth2授权URL。
文档: https://developer.work.weixin.qq.com/document/path/91022
"""
params = {
"appid": settings.wecom_corp_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "snsapi_base", # 静默授权
"state": "agent_login", # 标记为坐席登录
}
# 如果有 agentid 也加上
if getattr(settings, "wecom_agent_id", None):
params["agentid"] = str(settings.wecom_agent_id)
query = urllib.parse.urlencode(params)
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
@router.get("/agents/oauth/authorize")
async def get_oauth_authorize_url(
redirect_uri: str = Query(None, description="OAuth回调地址(可选,默认坐席端地址)"),
):
"""获取企微OAuth2授权URL(JSON格式,供前端跳转)。
前端调用此接口获取授权URL,然后自行跳转到企微授权页。
授权成功后企微会携带 code 回调到此接口的 redirect_uri。
Args:
redirect_uri: 授权成功后的回调地址(可选)
默认: https://itsupport.servyou.com.cn/itagent/
Returns:
JSON: { code: 0, data: { authorize_url: "https://open.weixin.qq.com/..." } }
"""
# 确定回调地址
if redirect_uri:
# 前端传入的回调地址
pass
else:
# 默认回调地址:坐席端首页
redirect_uri = "https://itsupport.servyou.com.cn/itagent/"
# 编码回调地址
encoded_redirect = urllib.parse.quote(redirect_uri, safe='')
# 构建授权URL
authorize_url = _build_agent_oauth_url(redirect_uri)
logger.info(f"生成坐席端OAuth授权URL: redirect_uri={redirect_uri}")
return success_response(data={
"authorize_url": authorize_url,
"redirect_uri": redirect_uri,
})
# OAuth 回调请求模型
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description="企微授权码")
state: str = Field(default="agent_login", description="state参数")
@router.post("/agents/oauth/callback")
async def oauth_callback(
body: OAuthCallbackRequest,
db: AsyncSession = Depends(get_db),
):
"""企微OAuth2回调处理(坐席端)。
用授权码换取员工ID,验证坐席身份,生成登录token。
Args:
body: { code: "xxx", state: "agent_login" }
db: 数据库会话
Returns:
JSON: { code: 0, data: { token, user_id, name, roles } }
"""
code = body.code
state = body.state
if not code:
raise AppException(2007, "授权码不能为空")
# 1. 用 code 换取员工身份
wecom_service = WecomService()
try:
oauth_info = await wecom_service.get_oauth_user_info(code)
user_id = oauth_info.get("userid", "")
if not user_id:
raise AppException(2007, "OAuth授权失败:未获取到员工ID")
except Exception as e:
logger.error(f"企微OAuth换取userid失败: {e}")
raise AppException(2007, f"OAuth授权失败: {str(e)}")
# 2. 获取员工详细信息(包含姓名)
employee_name = ""
try:
detail = await wecom_service.get_user_info(user_id)
employee_name = detail.get("name", "")
except Exception as e:
logger.warning(f"获取员工详细信息失败: user_id={user_id}, error={e}")
# 3. 验证是否为坐席
stmt = select(Agent).where(Agent.user_id == user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
raise AppException(2008, f"您不是坐席,无法通过企业微信登录")
# 4. 生成登录token
token = secrets_module.token_urlsafe(32)
redis_client = _get_redis()
if redis_client:
try:
# 存储 token -> agent信息(JSON格式)
token_data = {
"user_id": agent.user_id,
"name": agent.name,
"roles": [agent.role],
"login_source": "agent_oauth",
}
import json as json_module
await redis_client.setex(
f"user:token:{token}",
TOKEN_TTL_SECONDS,
json_module.dumps(token_data),
)
# 记录登录日志
logger.info(f"企微OAuth登录成功: user_id={user_id}, name={employee_name}")
except Exception as e:
logger.error(f"Token存储Redis失败: {e}")
raise AppException(1003, "登录失败,请重试")
return success_response(data={
"token": token,
"user_id": agent.user_id,
"name": employee_name or agent.name,
"role": agent.role,
"require_otp": agent.otp_secret is not None,
})
+233 -40
View File
@@ -1,30 +1,41 @@
# =============================================================================
# IT智能服务台 — 审批流程 API
# =============================================================================
# 说明:提供审批模板管理和跳转链接生成
# - 模板124(资源申请):跳转审批
# - 模板122(设备申请):API提交
# 说明:提供审批模板管理和API提交功能
# - 模板详情获取
# - API提交审批申请
# - 审批状态回调处理
# =============================================================================
import logging
import os
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
import redis.asyncio as aioredis
from app.config import settings
from app.utils.token_manager import ApprovalTokenManager
logger = logging.getLogger(__name__)
router = APIRouter()
# =============================================================================
# 审批模板配置(可配置化,后续可存入数据库)
# =============================================================================
# Redis客户端(依赖注入)
async def get_redis() -> aioredis.Redis:
"""获取Redis客户端依赖"""
from app.main import redis_client
return redis_client
# =============================================================================
# 企微审批模板配置(从环境变量读取)
# 审批模板配置(从环境变量读取)
# =============================================================================
# 环境变量:
# APPROVAL_TEMPLATE_RESOURCE - 资源申请模板ID
# APPROVAL_TEMPLATE_DEVICE - 设备申请模板ID
import os
APPROVAL_TEMPLATE_RESOURCE = os.getenv("APPROVAL_TEMPLATE_RESOURCE", "")
APPROVAL_TEMPLATE_DEVICE = os.getenv("APPROVAL_TEMPLATE_DEVICE", "")
@@ -35,7 +46,7 @@ if APPROVAL_TEMPLATE_RESOURCE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_RESOURCE] = {
"id": APPROVAL_TEMPLATE_RESOURCE,
"name": "资源申请",
"type": "jump", # 跳转审批
"type": "jump",
"keywords": ["申请资源", "要资源", "申请"],
}
@@ -43,7 +54,7 @@ if APPROVAL_TEMPLATE_DEVICE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_DEVICE] = {
"id": APPROVAL_TEMPLATE_DEVICE,
"name": "设备申请",
"type": "api", # API提交
"type": "api",
"keywords": ["申请设备", "要设备", "电脑", "笔记本"],
}
@@ -52,12 +63,11 @@ if APPROVAL_TEMPLATE_DEVICE:
# Schema 定义
# =============================================================================
class ApprovalTemplateResponse(BaseModel):
"""审批模板响应"""
id: str
name: str
type: str # "jump" 或 "api"
type: str
keywords: list[str]
@@ -73,11 +83,19 @@ class ApprovalJumpResponse(BaseModel):
template_name: str
class ApprovalContentItem(BaseModel):
"""审批表单控件内容"""
control: str # 控件类型: Text, Textarea, Number, Money, Date, Selector, Contact, etc.
id: str # 控件ID
value: dict # 控件值
class ApprovalSubmitRequest(BaseModel):
"""API提交审批请求"""
template_id: str
employee_id: str
content: dict # 审批内容
employee_id: str # 申请人userid
contents: list[ApprovalContentItem] # 表单内容
use_template_approver: int = 1 # 1-使用模板预设流程
class ApprovalSubmitResponse(BaseModel):
@@ -86,11 +104,98 @@ class ApprovalSubmitResponse(BaseModel):
template_name: str
class ApprovalCallbackRequest(BaseModel):
"""审批回调请求(XML解析后的模型)"""
sp_no: str
sp_name: str
template_id: str
apply_time: int
applyer_userid: str
sp_status: int # 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付
status_change_event: int # 1-提单 2-同意 3-驳回 4-转审 5-催办 6-撤销 8-通过后撤销 10-添加备注
# =============================================================================
# 企微API调用辅助函数
# =============================================================================
async def get_approval_token(redis: aioredis.Redis) -> str:
"""获取审批应用access_token"""
manager = ApprovalTokenManager(redis)
return await manager.get_token()
async def get_template_detail(access_token: str, template_id: str) -> dict:
"""获取审批模板详情
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail
返回模板内的控件构成及控件ID
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail"
params = {"access_token": access_token}
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json={"template_id": template_id})
result = response.json()
if result.get("errcode") != 0:
logger.error(f"获取模板详情失败: {result.get('errmsg')}")
raise Exception(f"获取模板详情失败: {result.get('errmsg')}")
return result
async def submit_approval_api(
access_token: str,
template_id: str,
creator_userid: str,
contents: list[dict],
use_template_approver: int = 1
) -> dict:
"""提交审批申请
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent
Args:
access_token: 审批应用access_token
template_id: 模板ID
creator_userid: 申请人userid
contents: 表单控件内容列表
use_template_approver: 1-使用模板预设流程
Returns:
{"sp_no": "审批单号"}
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent"
params = {"access_token": access_token}
payload = {
"creator_userid": creator_userid,
"template_id": template_id,
"use_template_approver": use_template_approver,
"apply_data": {
"contents": contents
}
}
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(f"提交审批失败: {result.get('errmsg')}")
raise Exception(f"提交审批失败: {result.get('errmsg')}")
return {"sp_no": result.get("sp_no")}
# =============================================================================
# API 端点
# =============================================================================
@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse])
async def get_approval_templates():
"""获取所有审批模板列表"""
@@ -102,27 +207,42 @@ async def get_approval_template(template_id: str):
"""获取指定审批模板详情"""
if template_id not in APPROVAL_TEMPLATES:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
return APPROVAL_TEMPLATES[template_id]
@router.get("/approval/templates/{template_id}/detail")
async def get_template_full_detail(
template_id: str,
redis: aioredis.Redis = Depends(get_redis)
):
"""获取审批模板完整详情(控件结构)"""
if template_id not in APPROVAL_TEMPLATES:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
try:
token = await get_approval_token(redis)
detail = await get_template_detail(token, template_id)
return detail
except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=str(e))
@router.post("/approval/jump", response_model=ApprovalJumpResponse)
async def create_approval_jump(request: ApprovalJumpRequest):
"""生成跳转审批链接(模板124跳转方式)"""
"""生成跳转审批链接"""
template = APPROVAL_TEMPLATES.get(request.template_id)
if not template:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "jump":
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持跳转方式")
# 生成跳转URL(企微审批链接格式)
# 实际URL需要根据企微配置生成
jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}"
return ApprovalJumpResponse(
@@ -132,27 +252,102 @@ async def create_approval_jump(request: ApprovalJumpRequest):
@router.post("/approval/submit", response_model=ApprovalSubmitResponse)
async def submit_approval(request: ApprovalSubmitRequest):
"""API提交审批(模板122 API方式)"""
async def submit_approval(
request: ApprovalSubmitRequest,
redis: aioredis.Redis = Depends(get_redis)
):
"""API提交审批申请"""
template = APPROVAL_TEMPLATES.get(request.template_id)
if not template:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "api":
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持API提交")
# TODO: 调用企微API提交审批
# 这里需要使用企微access_token调用审批API
# 实际实现需要根据企微审批API文档
try:
# 1. 获取审批token
token = await get_approval_token(redis)
return ApprovalSubmitResponse(
sp_no=f"SP{request.template_id[:8]}", # 模拟审批单号
template_name=template["name"],
)
# 2. 转换contents格式
contents = [item.model_dump() for item in request.contents]
# 3. 提交审批
result = await submit_approval_api(
access_token=token,
template_id=request.template_id,
creator_userid=request.employee_id,
contents=contents,
use_template_approver=request.use_template_approver
)
return ApprovalSubmitResponse(
sp_no=result["sp_no"],
template_name=template["name"],
)
except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=str(e))
@router.post("/approval/callback")
async def approval_callback(
sp_no: str = Query(...),
sp_name: str = Query(...),
template_id: str = Query(...),
apply_time: int = Query(...),
applyer_userid: str = Query(...),
sp_status: int = Query(...),
status_change_event: int = Query(...)
):
"""审批状态变化回调处理
对应企微审批回调事件: sys_approval_change
状态变化类型 (status_change_event):
1 - 提单
2 - 同意
3 - 驳回
4 - 转审
5 - 催办
6 - 撤销
8 - 通过后撤销
10 - 添加备注
审批单状态 (sp_status):
1 - 审批中
2 - 已通过
3 - 已驳回
4 - 已撤销
6 - 通过后撤销
7 - 已删除
10 - 已支付
"""
logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}")
# TODO: 根据业务需求处理审批状态变化
# 例如:
# - 审批通过后,更新IT服务台待办状态
# - 审批驳回后,通知申请人
# - 审批撤销后,关闭相关工单
event_map = {
1: "submitted",
2: "approved",
3: "rejected",
4: "transferred",
5: "reminded",
6: "revoked",
8: "revoked_after_approved",
10: "commented"
}
event_type = event_map.get(status_change_event, f"unknown_{status_change_event}")
logger.info(f"审批事件类型: {event_type}")
return {"errcode": 0, "errmsg": "ok"}
@router.get("/approval/keywords")
@@ -161,12 +356,10 @@ async def get_approval_keywords():
keywords = []
for template in APPROVAL_TEMPLATES.values():
for kw in template["keywords"]:
keywords.append(
{
"keyword": kw,
"template_id": template["id"],
"template_name": template["name"],
"type": template["type"],
}
)
keywords.append({
"keyword": kw,
"template_id": template["id"],
"template_name": template["name"],
"type": template["type"],
})
return keywords
+2 -1
View File
@@ -98,7 +98,8 @@ def _build_oauth_url(state: str, callback_url: str) -> str:
"agentid": settings.wecom_agent_id,
}
query = urllib.parse.urlencode(params)
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
@router.get("/sso/init")
+19 -4
View File
@@ -20,6 +20,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.database import get_db
from app.models.agent import Agent
from app.models.conversation import Conversation
@@ -41,7 +42,7 @@ from app.utils.response import AppException, success_response
from app.api.agents import get_current_agent
# RBAC 权限装饰器
from app.dependencies import require_role, require_permission
from app.dependencies import get_redis, require_role, require_permission
logger = logging.getLogger(__name__)
@@ -61,6 +62,7 @@ async def list_conversations(
page_size: int = Query(50, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
redis: aioredis.Redis = Depends(get_redis),
):
"""坐席获取会话列表(全局可见)。
@@ -82,7 +84,7 @@ async def list_conversations(
Returns:
Dict: 统一响应格式,包含会话列表和总数
"""
session_service = SessionService(db)
session_service = SessionService(db, redis_client=redis)
conversations, total = await session_service.get_conversations(
status=status,
agent_id=agent_id,
@@ -107,10 +109,18 @@ async def list_conversations(
for agent in result.scalars().all():
agent_name_map[agent.user_id] = agent.name
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段
# 批量获取员工头像(带缓存)
employee_ids = list(set([conv.employee_id for conv in conversations]))
employee_avatar_map = {}
for emp_id in employee_ids:
employee_avatar_map[emp_id] = await session_service._get_employee_avatar(emp_id)
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab / avatar 字段
items = []
for conv in conversations:
conv_data = ConversationResponse.model_validate(conv).model_dump()
# 员工头像(从缓存获取)
conv_data["avatar"] = employee_avatar_map.get(conv.employee_id, "")
# 是否为当前坐席的会话
conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id
# 坐席姓名(从批量查询结果中获取)
@@ -152,6 +162,7 @@ async def list_conversations(
async def get_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
"""获取会话详情。
@@ -162,10 +173,14 @@ async def get_conversation(
Returns:
Dict: 统一响应格式,包含会话详情
"""
session_service = SessionService(db)
session_service = SessionService(db, redis_client=redis)
conversation = await session_service.get_conversation(conversation_id)
# 获取员工头像(带缓存)
avatar = await session_service._get_employee_avatar(conversation.employee_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["avatar"] = avatar
return success_response(data=response_data)
+111 -2
View File
@@ -4,16 +4,27 @@
# 说明:提供员工相关的管理接口
# 接口列表:
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
# POST /api/employees/{employee_id}/avatar/refresh — 手动刷新员工头像
# =============================================================================
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.utils.response import success_response
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
from app.database import get_db
from app.core.config import settings
from app.models.employee import Employee
from app.dependencies import dep_redis
# 导入日志
import logging
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter(prefix="/employees", tags=["员工管理"])
@@ -114,3 +125,101 @@ async def update_employee_it_level(
it_level_source=request.source,
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
).model_dump())
# --------------------------------------------------------------------------
# 头像刷新 API
# --------------------------------------------------------------------------
class AvatarRefreshResponse(BaseModel):
"""头像刷新响应 Schema。"""
employee_id: str
avatar: str
message: str
async def get_redis() -> aioredis.Redis:
"""获取Redis客户端依赖"""
redis = await dep_redis()
if redis is None:
raise HTTPException(status_code=500, detail="Redis连接不可用")
return redis
@router.post("/{employee_id}/avatar/refresh", response_model=dict)
async def refresh_employee_avatar(
employee_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
"""手动刷新员工头像。
调用企微通讯录API获取最新头像URL,更新数据库并刷新Redis缓存。
支持手动触发头像更新,适用于头像URL过期或需要立即更新的场景。
Args:
employee_id: 员工ID
Returns:
更新后的头像URL
"""
from datetime import datetime
from app.services.session_service import SessionService
# 1. 查找员工记录
result = await db.execute(
select(Employee).where(
Employee.employee_id == employee_id,
Employee.corp_id == settings.wecom_corp_id
)
)
employee = result.scalars().first()
if not employee:
raise HTTPException(status_code=404, detail=f"员工不存在: {employee_id}")
# 2. 使用 SessionService 从企微API获取最新头像
session_service = SessionService(db, redis_client=redis)
new_avatar = ""
try:
# 调用企微API获取最新头像
from app.services.wecom_service import WeComService
wecom_service = WeComService()
user_info = await wecom_service.get_user_info(employee_id)
new_avatar = user_info.get("avatar", "")
logger.info(f"企微API返回头像: employee_id={employee_id}, avatar={'有值(' + str(len(new_avatar)) + '字符)' if new_avatar else ''}")
# 3. 更新数据库
employee.avatar = new_avatar
employee.avatar_updated_at = datetime.utcnow()
await db.commit()
# 4. 刷新Redis缓存
cache_key = f"employee:avatar:{employee_id}"
if redis:
try:
if new_avatar:
await redis.setex(cache_key, SessionService.AVATAR_CACHE_TTL, new_avatar)
else:
# 如果头像为空,删除缓存
await redis.delete(cache_key)
except Exception as e:
logger.warning(f"刷新Redis头像缓存失败: employee_id={employee_id}, error={e}")
return success_response(data=AvatarRefreshResponse(
employee_id=employee_id,
avatar=new_avatar,
message="头像刷新成功" if new_avatar else "企微API未返回头像,已使用原头像",
).model_dump())
except Exception as e:
logger.error(f"刷新头像失败: employee_id={employee_id}, error={e}")
# 返回原头像,不阻塞流程
return success_response(data=AvatarRefreshResponse(
employee_id=employee_id,
avatar=employee.avatar,
message=f"头像刷新失败,使用原头像: {str(e)}",
).model_dump())
+117
View File
@@ -0,0 +1,117 @@
# =============================================================================
# 企微IT智能服务台 — 员工 API
# =============================================================================
# 说明:提供员工相关的管理接口
# 接口列表:
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
# =============================================================================
from fastapi import APIRouter
from pydantic import BaseModel, Field, field_validator
from app.utils.response import success_response
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
# 导入日志
import logging
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter(prefix="/employees", tags=["员工管理"])
# --------------------------------------------------------------------------
# 请求 Schema
# --------------------------------------------------------------------------
class ItLevelUpdateRequest(BaseModel):
"""IT技能等级更新请求 Schema。"""
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
@field_validator("it_level")
@classmethod
def validate_it_level(cls, v: str) -> str:
"""校验IT等级值是否合法。"""
if v not in VALID_IT_LEVELS:
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
return v
@field_validator("source")
@classmethod
def validate_source(cls, v: str) -> str:
"""校验等级来源值是否合法。"""
if v not in VALID_LEVEL_SOURCES:
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
return v
class ItLevelUpdateResponse(BaseModel):
"""IT技能等级更新响应 Schema。"""
employee_id: str
it_level: str
it_level_source: str
message: str
# --------------------------------------------------------------------------
# Mock 员工数据存储(IT 等级映射)
# --------------------------------------------------------------------------
# 简单的内存存储,key 为 employee_idvalue 为 it_level
MOCK_EMPLOYEE_IT_LEVELS: dict = {
"emp-001": "silver",
"emp-002": "gold",
"emp-003": "bronze",
"emp-004": "platinum",
"emp-005": "diamond",
"emp-006": "silver",
"emp-007": "star",
"emp-008": "king",
}
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.put("/{employee_id}/it-level")
async def update_employee_it_level(
employee_id: str,
request: ItLevelUpdateRequest,
):
"""更新员工IT技能等级。
坐席可以手动调整员工的IT技能等级,等级来源标记为 manual。
更新后等级立即生效,并记录来源以便追溯。
Args:
employee_id: 员工ID
request: 等级更新请求
Returns:
更新结果
"""
# 更新内存中的等级
old_level = MOCK_EMPLOYEE_IT_LEVELS.get(employee_id, "silver")
MOCK_EMPLOYEE_IT_LEVELS[employee_id] = request.it_level
# 构造等级名称映射
level_names = {
"bronze": "青铜",
"silver": "白银",
"gold": "黄金",
"platinum": "铂金",
"diamond": "钻石",
"star": "星耀",
"king": "王者",
}
return success_response(data=ItLevelUpdateResponse(
employee_id=employee_id,
it_level=request.it_level,
it_level_source=request.source,
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
).model_dump())
+48 -3
View File
@@ -260,8 +260,9 @@ async def get_oauth_authorize_url(
encoded_redirect = quote(f"{default_origin}/itportal/", safe="")
# 构造企微OAuth2静默授权URLsnsapi_base:用户无感知)
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com
authorize_url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize"
f"https://open.work.weixin.qq.com/connect/oauth2/authorize"
f"?appid={corp_id}"
f"&redirect_uri={encoded_redirect}"
f"&response_type=code"
@@ -328,6 +329,7 @@ async def oauth_callback(
position = ""
avatar = ""
# 2.1 获取员工详细信息(包含头像)
try:
detail = await wecom_service.get_user_info(employee_id)
employee_name = detail.get("name", "")
@@ -337,8 +339,51 @@ async def oauth_callback(
department = ",".join(str(d) for d in dept_ids) if dept_ids else ""
position = detail.get("position", "")
avatar = detail.get("avatar", "")
except Exception:
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}")
# 调试日志:检查企微API返回的完整数据
logger.info(f"企微用户详情返回: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
except Exception as e:
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
# 2.2 将员工信息保存到数据库(包含头像)
try:
from app.models.employee import Employee
from sqlalchemy import select
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
# 【FE-UA-005 优化】每次登录强制更新头像URL,确保获取最新头像
if avatar:
employee.avatar = avatar
employee.avatar_updated_at = datetime.utcnow()
logger.info(f"更新员工头像: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
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)
logger.info(f"创建员工记录(含头像): employee_id={employee_id}")
await db.commit()
except Exception as e:
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
# 不阻塞登录流程,继续执行
# 3. 生成 Bearer Token(与坐席端一致:secrets.token_urlsafe(32)
token = secrets.token_urlsafe(32)
+32 -28
View File
@@ -33,9 +33,8 @@ from app.schemas.message import MessageCreate, MessageResponse
from app.api.agents import get_current_agent
# RBAC 权限装饰器
from app.dependencies import require_permission
from app.dependencies import require_permission, get_current_user, UserInfo
from app.services.wecom_service import WecomService
from app.services.ws_manager import manager
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
@@ -60,6 +59,7 @@ async def list_messages(
conversation_id: str,
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
before: Optional[str] = Query(None, description="加载此消息ID之前的消息(向上翻页)"),
current_agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""获取会话消息列表(分页)。
@@ -142,6 +142,7 @@ async def send_message(
conversation_id: str,
body: MessageCreate,
db: AsyncSession = Depends(get_db),
current_user: UserInfo = Depends(get_current_user),
):
"""坐席发送消息。
@@ -222,37 +223,40 @@ async def send_message(
await db.flush() # 刷新以获取消息 ID
# 5. 调用企微 API 发送消息给员工
# 注意:只有 text 类型消息才需要调用企微 API 推送给员工
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
# 跳过 Redis 连可避免无谓的网络开销,减少截图发送超时
if body.msg_type == "text":
# dev 模式短路:直接跳过企微推送,避免 invalid corpid 噪音
from app.config import settings
if getattr(settings, 'dev_mode', False):
logger.debug(f"[DEV] 跳过企微推送: msg_id={message.id}")
else:
try:
import redis.asyncio as aioredis
# 5. 移除企微 API 调用,仅通过 WebSocket 推送到 H5
# 企微提醒由定时任务处理(超时未回复场景)
# 只保留 WebSocket 推送逻辑
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
# 6. 更新会话的最后坐席回复时间(用于超时提醒判断)
conversation.last_agent_reply_at = datetime.now()
conversation.reminder_sent = False # 重置提醒标记,允许再次发送提醒
conversation.pending_close_at = datetime.now() + timedelta(minutes=10) # 10分钟后待关闭
db.add(conversation)
await wecom_service.send_text_message(
conversation.employee_id, body.content
)
await wecom_service.close()
await redis_client.close()
except Exception as e:
# 企微 API 调用失败不阻塞消息存储
logger.warning(f"企微消息发送失败(消息已存储): {e}")
# 6. 更新消息状态为已发送
# 7. 更新消息状态为已发送
message.status = "sent"
await db.flush()
# 7. 通过 WebSocket 推送消息给 H5 用户
# 做什么:构建 new_message 事件,推送给会话的员工
# 为什么:实现双通道推送(企微消息 + WebSocket),H5 用户可以实时收到消息
try:
# 构建消息载荷
msg_payload = MessageResponse.model_validate(message).model_dump()
# 构建 WebSocket 事件
ws_event = {
"type": "new_message",
"data": msg_payload,
}
# 推送给会话的员工(H5用户)
await manager.send_to_employee(conversation.employee_id, ws_event)
logger.debug(f"WebSocket消息推送成功: employee_id={conversation.employee_id}, msg_id={message.id}")
except Exception as e:
# WebSocket 推送失败不阻塞响应(员工可能未打开H5页面)
logger.warning(f"WebSocket消息推送失败(H5用户可能不在线): {e}")
# 转换为响应格式
response_data = MessageResponse.model_validate(message).model_dump()
return success_response(data=response_data)
+1 -1
View File
@@ -115,7 +115,7 @@ async def websocket_endpoint(
# token 不存在(已过期或伪造)
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, token={token[:20] if token else 'empty'}..., 原因=token无效或已过期")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=token无效或已过期")
return
if stored_agent_id != agent_id:
+7 -2
View File
@@ -40,6 +40,8 @@ class Settings(BaseSettings):
wecom_agent_id: str = "1000002"
# 应用Secret(在企微管理后台 > 应用管理 > 自建应用 中查看)
wecom_secret: str = "your-agent-secret"
# 审批应用Secret(在企微管理后台 > 应用管理 > 审批 > 查看Secret
wecom_approval_secret: str = ""
# 回调Token(在企微管理后台 > 应用管理 > 接收消息 中设置)
wecom_token: str = "your-callback-token"
# 回调EncodingAESKey43位字符串,用于消息加解密)
@@ -57,7 +59,8 @@ class Settings(BaseSettings):
# ----------------------------------------------------------------------
# Redis 连接地址
# Docker 环境使用容器名 redis,本地开发使用 localhost
redis_url: str = "redis://localhost:6379/0"
# 从环境变量 REDIS_URL 读取,格式: redis://:password@host:port/db
redis_url: str = "" # 默认为空,由环境变量 REDIS_URL 提供
# ----------------------------------------------------------------------
# 服务配置
@@ -192,7 +195,9 @@ class Settings(BaseSettings):
Returns:
aioredis.Redis: 配置好的 Redis 异步客户端
"""
return aioredis.from_url(self.redis_url, protocol=2)
# 如果 redis_url 为空,使用默认值
url = self.redis_url if self.redis_url else "redis://localhost:6379/0"
return aioredis.from_url(url, protocol=2)
# 创建全局配置实例
+4
View File
@@ -161,6 +161,10 @@ async def init_shared_services():
"""
global _redis_pool
_redis_pool = settings.create_redis_client()
# 注入 Redis 客户端到 cache_service(解决 WebSocket 认证时 redis=None 的问题)
from app.services.cache_service import cache_service
cache_service.redis = _redis_pool
logger.info("共享服务初始化完成")
+68
View File
@@ -28,6 +28,8 @@ from app.api.router import api_router
from app.dependencies import init_shared_services, cleanup_shared_services
# 导入异常处理器和异常类
from app.utils.response import AppException, app_exception_handler
# 导入定时任务
from app.tasks.reminder_task import check_unreplied_sessions
# 配置日志格式
logging.basicConfig(
@@ -88,6 +90,9 @@ async def lifespan(app: FastAPI):
# 初始化默认数据
await _init_default_data()
# 启动超时提醒定时任务
_start_scheduler()
logger.info("✅ 企微IT智能服务台启动完成")
yield # 应用运行中
@@ -95,12 +100,75 @@ async def lifespan(app: FastAPI):
# ===== 关闭事件 =====
logger.info("👋 企微IT智能服务台关闭中...")
# 停止超时提醒定时任务
_stop_scheduler()
# 清理共享服务实例(关闭 Redis 连接、httpx 连接池等)
await cleanup_shared_services()
logger.info("✅ 企微IT智能服务台已关闭")
# --------------------------------------------------------------------------
# 定时任务调度器
# --------------------------------------------------------------------------
# 全局调度器实例
_scheduler = None
def _start_scheduler():
"""启动定时任务调度器。
启动 APScheduler 调度器,注册超时提醒定时任务。
每 30 秒检查一次超时未回复的会话。
"""
global _scheduler
if _scheduler is not None:
logger.warning("调度器已启动,跳过")
return
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
_scheduler = AsyncIOScheduler()
# 注册超时提醒任务(每 30 秒执行一次)
_scheduler.add_job(
check_unreplied_sessions,
'interval',
seconds=30,
id='check_unreplied_sessions',
name='检查超时未回复会话',
replace_existing=True,
)
_scheduler.start()
logger.info("✅ 超时提醒定时任务已启动(每 30 秒执行一次)")
except Exception as e:
logger.error(f"启动定时任务调度器失败: {e}")
# 定时任务启动失败不阻塞应用启动
def _stop_scheduler():
"""停止定时任务调度器。
在应用关闭时调用,确保定时任务正确关闭。
"""
global _scheduler
if _scheduler is None:
return
try:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("✅ 超时提醒定时任务已停止")
except Exception as e:
logger.error(f"停止定时任务调度器失败: {e}")
# --------------------------------------------------------------------------
# 配置校验(启动时检查关键配置项是否为占位符)
# --------------------------------------------------------------------------
+32 -3
View File
@@ -3,7 +3,7 @@
# =============================================================================
# 说明:对应数据库 conversations 表,存储所有会话信息
# 核心概念:每个员工的每次咨询对应一个会话(Conversation
# 会话状态流转:ai_handling → queued → serving → resolved
# 会话状态流转:ai_handling → queued → serving → pending_close → resolved
# =============================================================================
import uuid
@@ -29,7 +29,7 @@ class Conversation(Base):
department: 员工部门
position: 员工岗位
level: 员工等级(用于 VIP 判断)
status: 会话状态(ai_handling/queued/serving/resolved
status: 会话状态(ai_handling/queued/serving/pending_close/resolved
is_vip: VIP标记(基于企微通讯录规则自动匹配)
is_pinned: 置顶标记(坐席手动操作)
is_todo: 代办标记(坐席手动操作)
@@ -113,7 +113,7 @@ class Conversation(Base):
String(20),
nullable=False,
default="queued",
comment="会话状态: ai_handling/queued/serving/resolved",
comment="会话状态: ai_handling/queued/serving/pending_close/resolved",
)
# VIP标记(基于企微通讯录API规则自动匹配)
@@ -240,6 +240,35 @@ class Conversation(Base):
comment="最后消息时间",
)
# 最后坐席回复时间(用于超时提醒判断)
last_agent_reply_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="最后坐席回复时间",
)
# 是否已发送超时提醒(避免重复发送)
reminder_sent: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否已发送超时提醒",
)
# 提醒发送时间
reminder_sent_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="提醒发送时间",
)
# 待关闭时间(坐席回复后 10 分钟自动标记待关闭)
pending_close_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="待关闭时间",
)
# 最后消息摘要(会话列表预览用,截取消息前256字符)
last_message_summary: Mapped[str] = mapped_column(
String(256),
+7
View File
@@ -118,6 +118,13 @@ class Employee(Base):
comment="头像URL",
)
# 头像最后更新时间(从企微API获取后更新)
avatar_updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=True,
comment="头像最后更新时间",
)
# 激活状态(企微通讯录返回: 1=已激活, 2=已禁用, 4=未激活)
status: Mapped[int] = mapped_column(
default=1,
+1
View File
@@ -267,6 +267,7 @@ class ConversationResponse(BaseModel):
id: str
employee_id: str
employee_name: str
avatar: str = Field(default="", description="员工头像URL")
department: str
position: str
level: str
+6 -2
View File
@@ -167,7 +167,7 @@ class QrcodeService:
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。
URL 格式:
https://open.weixin.qq.com/connect/oauth2/authorize
https://open.work.weixin.qq.com/connect/oauth2/authorize
?appid={corp_id}
&redirect_uri={callback}
&response_type=code
@@ -175,6 +175,9 @@ class QrcodeService:
&state={ticket}
#wechat_redirect
注意:必须使用 open.work.weixin.qq.com(企业微信开放平台),
而不是 open.weixin.qq.com(微信开放平台)。
Args:
ticket: 扫码登录票据
@@ -194,7 +197,8 @@ class QrcodeService:
"state": ticket,
}
query = urlencode(params)
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com,不是 open.weixin.qq.com
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
def _get_scan_callback_url(self) -> str:
"""获取 OAuth 回调地址。
+59
View File
@@ -0,0 +1,59 @@
# =============================================================================
# 企微IT智能服务台 — 超时提醒服务
# =============================================================================
# 说明:提供发送超时提醒企微消息的功能
# 在坐席回复后员工长时间未回复时,发送企微提醒消息
# =============================================================================
import logging
from app.config import settings
from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
# 超时提醒消息内容
REMINDER_MESSAGE = (
"IT服务提醒:您有新的消息未查看,"
"咨询将在10分钟后标记为待关闭,请尽快点击处理 👉 "
"https://itsupport.servyou.com.cn/itdesk/"
)
async def send_reminder_message(employee_id: str) -> bool:
"""发送超时提醒企微消息。
当坐席回复后员工超过3分钟未回复时,发送企微消息提醒员工查看。
Args:
employee_id: 员工的企微 UserID
Returns:
bool: 发送是否成功
Raises:
Exception: 发送失败时抛出异常
"""
try:
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
try:
result = await wecom_service.send_text_message(
employee_id, REMINDER_MESSAGE
)
if result.get("errcode") == 0:
logger.info(f"超时提醒发送成功: employee_id={employee_id}")
return True
else:
logger.warning(
f"超时提醒发送失败: employee_id={employee_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
return False
finally:
await wecom_service.close()
await redis_client.close()
except Exception as e:
logger.error(f"发送超时提醒异常: employee_id={employee_id}, error={e}")
raise
+50 -11
View File
@@ -12,7 +12,7 @@
import logging
from datetime import datetime
from typing import List, Optional
from typing import Any, List, Optional
from uuid import UUID
from sqlalchemy import and_, case, desc, func, select
@@ -43,15 +43,18 @@ class SessionService:
self,
db: AsyncSession,
wecom_service: Optional[WecomService] = None,
redis_client: Optional[Any] = None,
):
"""初始化会话状态管理服务。
Args:
db: 异步数据库会话
wecom_service: 企微 API 服务(用于坐席接入时发送通知,可选)
redis_client: Redis客户端(用于头像缓存,可选)
"""
self.db = db
self.wecom_service = wecom_service
self.redis_client = redis_client
# --------------------------------------------------------------------------
# 创建会话
@@ -819,12 +822,16 @@ class SessionService:
# 邀请功能(P0-09~P0-11):坐席邀请员工/部门加入会话
# ======================================================================
async def _get_employee_avatar(self, employee_id: str) -> str:
"""获取员工头像URL。
# 头像缓存 TTL7天
AVATAR_CACHE_TTL = 7 * 24 * 60 * 60
做什么:从 employees 表或企微通讯录API获取头像
为什么:邀请参与者时需要展示头像,前端无法单独获取被邀请人头像
优先级:employees 表(本地缓存) > 企微API(实时获取)
async def _get_employee_avatar(self, employee_id: str) -> str:
"""获取员工头像URL(带Redis缓存)。
优先级:
1. Redis 缓存(最快)
2. employees 表
3. 企微API(获取后存入Redis缓存)
Args:
employee_id: 企微员工UserID
@@ -832,25 +839,57 @@ class SessionService:
Returns:
str: 头像URL,获取不到返回空字符串
"""
# 1. 优先从 employees 表查(本地缓存,速度快)
cache_key = f"employee:avatar:{employee_id}"
# 1. 优先从 Redis 缓存获取
if self.redis_client:
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
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
result = await self.db.execute(
select(Employee.avatar).where(Employee.employee_id == employee_id)
select(Employee.avatar).where(
Employee.employee_id == employee_id,
Employee.corp_id == settings.wecom_corp_id
)
)
row = result.first()
if row and row[0]:
logger.info(f"从employees表获取头像: employee_id={employee_id}, avatar={row[0][:50]}...")
# 存入 Redis 缓存
if self.redis_client:
try:
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, row[0])
except Exception as e:
logger.warning(f"存入Redis头像缓存失败: employee_id={employee_id}, error={e}")
return row[0]
else:
logger.info(f"employees表无头像记录: employee_id={employee_id}")
# 2. employees 表没有,尝试从企微通讯录API获取
# 3. employees 表没有,尝试从企微通讯录API获取
avatar = ""
if self.wecom_service:
try:
user_info = await self.wecom_service.get_user_info(employee_id)
avatar = user_info.get("avatar", "")
return avatar
logger.info(f"企微API返回头像: employee_id={employee_id}, avatar={'有值(' + str(len(avatar)) + '字符)' if avatar else ''}")
# 存入 Redis 缓存(即使为空也缓存,避免频繁请求API)
if self.redis_client and avatar:
try:
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}")
except Exception as e:
logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}")
return ""
return avatar
async def invite_participants(
self,
+100
View File
@@ -0,0 +1,100 @@
# =============================================================================
# 企微IT智能服务台 — 超时提醒定时任务
# =============================================================================
# 说明:定时检查超时未回复的会话,发送企微提醒消息
# 运行频率:每 30 秒执行一次
# 超时逻辑:
# 1. 坐席回复后 3 分钟员工未回复 -> 发送企微提醒(只发 1 次)
# 2. 坐席回复后 10 分钟员工未回复 -> 标记会话为 pending_close
# =============================================================================
import logging
from datetime import datetime, timedelta
from sqlalchemy import select
from app.models.conversation import Conversation
from app.services.reminder_service import send_reminder_message
logger = logging.getLogger(__name__)
# 超时配置(分钟)
REMINDER_TIMEOUT_MINUTES = 3 # 未回复超时时间
CLOSE_TIMEOUT_MINUTES = 10 # 自动待关闭时间
async def check_unreplied_sessions():
"""检查超时未回复的会话,发送提醒并标记待关闭。
此函数由 APScheduler 定时调用(每 30 秒)。
执行流程:
1. 查找需要发送提醒的会话(坐席回复超过3分钟,员工未回复且未发送过提醒)
2. 发送企微提醒消息
3. 标记已发送提醒
4. 查找需要标记待关闭的会话(坐席回复超过10分钟)
5. 更新会话状态为 pending_close
"""
# 导入数据库 session 工厂
from app.database import _get_session_factory
async_session_factory = _get_session_factory()
async with async_session_factory() as db:
try:
# 1. 查找需要发送提醒的会话
# 条件:active 状态 + 有坐席回复 + 超过3分钟未回复 + 未发送过提醒
reminder_threshold = datetime.now() - timedelta(minutes=REMINDER_TIMEOUT_MINUTES)
reminder_stmt = select(Conversation).where(
Conversation.status == "serving",
Conversation.last_agent_reply_at.isnot(None),
Conversation.last_agent_reply_at < reminder_threshold,
Conversation.reminder_sent == False,
)
result = await db.execute(reminder_stmt)
sessions_to_remind = result.scalars().all()
logger.info(f"发现 {len(sessions_to_remind)} 个需要发送提醒的会话")
# 2. 发送企微提醒消息
for session in sessions_to_remind:
try:
success = await send_reminder_message(session.employee_id)
if success:
# 3. 标记已发送提醒
session.reminder_sent = True
session.reminder_sent_at = datetime.now()
logger.info(f"会话 {session.id} 已发送提醒: employee_id={session.employee_id}")
else:
logger.warning(f"会话 {session.id} 提醒发送失败,跳过")
except Exception as e:
logger.error(f"会话 {session.id} 发送提醒异常: {e}")
continue
# 4. 查找需要标记待关闭的会话
# 条件:active 状态 + 有坐席回复 + 超过10分钟未回复
close_threshold = datetime.now() - timedelta(minutes=CLOSE_TIMEOUT_MINUTES)
close_stmt = select(Conversation).where(
Conversation.status == "serving",
Conversation.last_agent_reply_at.isnot(None),
Conversation.last_agent_reply_at < close_threshold,
)
result = await db.execute(close_stmt)
sessions_to_close = result.scalars().all()
logger.info(f"发现 {len(sessions_to_close)} 个需要标记待关闭的会话")
# 5. 更新会话状态为 pending_close
for session in sessions_to_close:
session.status = "pending_close"
logger.info(f"会话 {session.id} 已标记为待关闭: employee_id={session.employee_id}")
# 提交数据库变更
await db.commit()
logger.info("超时检查任务执行完成")
except Exception as e:
await db.rollback()
logger.error(f"超时检查任务执行异常: {e}")
raise
+73
View File
@@ -18,6 +18,79 @@ from app.config import settings
logger = logging.getLogger(__name__)
class ApprovalTokenManager:
"""企微审批应用 access_token 缓存管理器。
专门用于审批应用的token管理,支持不同的Secret。
使用独立的Redis缓存key,与普通应用token隔离。
Attributes:
redis: Redis 异步客户端
corp_id: 企业ID
corp_secret: 审批应用Secret
"""
# 独立的Redis缓存key
CACHE_KEY = "wecom:approval_access_token"
TOKEN_EXPIRES = 7200
BUFFER_SECONDS = 300
def __init__(self, redis_client: aioredis.Redis, corp_secret: str = None):
"""初始化审批token管理器。
Args:
redis_client: Redis 异步客户端实例
corp_secret: 审批应用Secret,默认从settings读取
"""
self.redis = redis_client
self.corp_id = settings.wecom_corp_id
self.corp_secret = corp_secret or settings.wecom_approval_secret
self.client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=10.0))
async def get_token(self) -> str:
"""获取审批应用的 access_token。"""
cached = await self.redis.get(self.CACHE_KEY)
if cached:
logger.debug("从缓存获取审批 access_token")
return cached.decode("utf-8")
return await self._refresh_token()
async def _refresh_token(self) -> str:
"""调用企微 API 刷新审批 access_token。"""
logger.info("刷新审批 access_token")
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.corp_secret,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode") != 0:
error_msg = result.get("errmsg", "未知错误")
logger.error(f"获取审批 access_token 失败: {error_msg}")
raise Exception(f"企微API错误: {error_msg}")
access_token = result["access_token"]
expires_in = result.get("expires_in", self.TOKEN_EXPIRES)
cache_ttl = max(expires_in - self.BUFFER_SECONDS, 60)
await self.redis.setex(self.CACHE_KEY, cache_ttl, access_token)
logger.info(f"审批 access_token 刷新成功,TTL={cache_ttl}")
return access_token
except httpx.HTTPError as e:
logger.error(f"获取审批 access_token 网络错误: {e}")
raise Exception(f"网络错误: {e}") from e
async def close(self) -> None:
"""关闭 HTTP 客户端。"""
await self.client.aclose()
class TokenManager:
"""企微 access_token 缓存管理器。
@@ -0,0 +1,38 @@
-- =============================================================================
-- 企微IT智能服务台 — 数据库迁移脚本
-- =============================================================================
-- 说明:为 conversations 表添加超时提醒相关字段
-- 执行时机:在部署 MSG-P2-02 功能前执行
-- 兼容性:PostgreSQL
-- =============================================================================
-- 添加超时提醒相关字段
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS last_agent_reply_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
ADD COLUMN IF NOT EXISTS reminder_sent BOOLEAN DEFAULT FALSE NOT NULL,
ADD COLUMN IF NOT EXISTS reminder_sent_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
ADD COLUMN IF NOT EXISTS pending_close_at TIMESTAMP WITH TIME ZONE DEFAULT NULL;
-- 添加索引以优化查询性能
CREATE INDEX IF NOT EXISTS idx_conversations_last_agent_reply_at
ON conversations(last_agent_reply_at)
WHERE last_agent_reply_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conversations_reminder_sent
ON conversations(reminder_sent)
WHERE reminder_sent = FALSE;
CREATE INDEX IF NOT EXISTS idx_conversations_pending_close_at
ON conversations(pending_close_at)
WHERE pending_close_at IS NOT NULL;
-- 回滚脚本(如果需要回滚)
-- ALTER TABLE conversations
-- DROP COLUMN IF EXISTS last_agent_reply_at,
-- DROP COLUMN IF EXISTS reminder_sent,
-- DROP COLUMN IF EXISTS reminder_sent_at,
-- DROP COLUMN IF EXISTS pending_close_at;
--
-- DROP INDEX IF EXISTS idx_conversations_last_agent_reply_at;
-- DROP INDEX IF EXISTS idx_conversations_reminder_sent;
-- DROP INDEX IF EXISTS idx_conversations_pending_close_at;
+4
View File
@@ -65,6 +65,10 @@ slowapi==0.1.9
# --------------------------------------------------------------------------
# python-dotenv: 从 .env 文件加载环境变量到 os.environ
python-dotenv==1.0.1
# wordfilter: 敏感词过滤(用于坐席消息内容审核 v0.6.0+)
wordfilter==0.2.7
# APScheduler: 定时任务调度器(用于超时提醒等后台任务)
apscheduler==3.10.4
# --------------------------------------------------------------------------
# OTP 二次验证