docs: 移动蓝绿部署指南到 troubleshooting 目录
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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_id,value 为 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
@@ -260,8 +260,9 @@ async def get_oauth_authorize_url(
|
||||
encoded_redirect = quote(f"{default_origin}/itportal/", safe="")
|
||||
|
||||
# 构造企微OAuth2静默授权URL(snsapi_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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
# 回调EncodingAESKey(43位字符串,用于消息加解密)
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 创建全局配置实例
|
||||
|
||||
@@ -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("共享服务初始化完成")
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 配置校验(启动时检查关键配置项是否为占位符)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 回调地址。
|
||||
|
||||
@@ -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
|
||||
@@ -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。
|
||||
# 头像缓存 TTL:7天
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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 二次验证
|
||||
|
||||
Reference in New Issue
Block a user