docs: 移动蓝绿部署指南到 troubleshooting 目录
This commit is contained in:
+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:
|
||||
|
||||
Reference in New Issue
Block a user