WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 管理员用户管理 API
|
||||
# =============================================================================
|
||||
# 说明:管理员用户的 CRUD API
|
||||
# 端点:
|
||||
# GET /api/admin/users — 获取管理员列表
|
||||
# POST /api/admin/users — 创建管理员
|
||||
# GET /api/admin/users/{id} — 获取管理员详情
|
||||
# PUT /api/admin/users/{id} — 更新管理员
|
||||
# DELETE /api/admin/users/{id} — 删除管理员
|
||||
# POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import UserInfo, get_current_user, require_role
|
||||
from app.schemas.admin_user import (
|
||||
AdminUserCreateRequest,
|
||||
AdminUserListResponse,
|
||||
AdminUserResetPasswordRequest,
|
||||
AdminUserResponse,
|
||||
AdminUserUpdateRequest,
|
||||
)
|
||||
from app.services.admin_user_service import AdminUserService
|
||||
from app.utils.response import AppException, success_response
|
||||
from app.utils.error_codes import ErrorCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/admin/users", tags=["管理员用户管理"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 0. GET /api/admin/users/me — 获取当前登录用户信息
|
||||
# =============================================================================
|
||||
@router.get("/me", response_model=None)
|
||||
async def get_current_admin_user(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前登录的管理员用户信息。
|
||||
|
||||
无需额外权限,任何已登录用户都可以访问。
|
||||
|
||||
Args:
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
当前用户信息
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
agent = await service.get_user_by_user_id(current_user.employee_id)
|
||||
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.NOT_FOUND, "用户不存在")
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. GET /api/admin/users — 获取管理员列表
|
||||
# =============================================================================
|
||||
@router.get("", response_model=None)
|
||||
async def list_admin_users(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
is_active: Optional[bool] = Query(None, description="是否激活(true=在线,false=离线)"),
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户列表。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
is_active: 按激活状态过滤
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
管理员列表
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
items, total = await service.list_admin_users(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
# 转换为响应格式
|
||||
user_responses = []
|
||||
for agent in items:
|
||||
user_responses.append(AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
))
|
||||
|
||||
return success_response(data=AdminUserListResponse(
|
||||
items=user_responses,
|
||||
total=total,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. POST /api/admin/users — 创建管理员
|
||||
# =============================================================================
|
||||
@router.post("", response_model=None)
|
||||
async def create_admin_user(
|
||||
body: AdminUserCreateRequest,
|
||||
current_user: UserInfo = Depends(require_role("super_admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建管理员用户。
|
||||
|
||||
需要 super_admin 角色。
|
||||
|
||||
Args:
|
||||
body: 创建请求
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
创建的用户信息
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
agent = await service.create_admin_user(
|
||||
user_id=body.user_id,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
password=body.password,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"创建管理员失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "创建管理员失败")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. GET /api/admin/users/{id} — 获取管理员详情
|
||||
# =============================================================================
|
||||
@router.get("/{id}", response_model=None)
|
||||
async def get_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户详情。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
用户详情
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
agent = await service.get_user_by_id(id)
|
||||
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.NOT_FOUND, "用户不存在")
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. PUT /api/admin/users/{id} — 更新管理员
|
||||
# =============================================================================
|
||||
@router.put("/{id}", response_model=None)
|
||||
async def update_admin_user(
|
||||
id: str,
|
||||
body: AdminUserUpdateRequest,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新管理员用户。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
- admin 角色只能更新普通 admin
|
||||
- super_admin 角色可以更新所有用户
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
body: 更新请求
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的用户信息
|
||||
"""
|
||||
# 权限检查:非 super_admin 不能修改 super_admin
|
||||
if "super_admin" not in current_user.roles:
|
||||
target = await AdminUserService(db).get_user_by_id(id)
|
||||
if target and target.role == "super_admin":
|
||||
raise AppException(ErrorCode.FORBIDDEN, "无法修改超级管理员")
|
||||
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
agent = await service.update_admin_user(
|
||||
id=id,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
is_active=body.is_active,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"更新管理员失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "更新管理员失败")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. DELETE /api/admin/users/{id} — 删除管理员
|
||||
# =============================================================================
|
||||
@router.delete("/{id}", response_model=None)
|
||||
async def delete_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(require_role("super_admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除管理员用户。
|
||||
|
||||
需要 super_admin 角色。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
删除结果
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
await service.delete_admin_user(id)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={"message": "删除成功"})
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"删除管理员失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "删除管理员失败")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
# =============================================================================
|
||||
@router.post("/{id}/reset-password", response_model=None)
|
||||
async def reset_password(
|
||||
id: str,
|
||||
body: AdminUserResetPasswordRequest,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""重置管理员密码。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
body: 重置请求
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
重置结果
|
||||
"""
|
||||
# 权限检查:非 super_admin 不能重置 super_admin 的密码
|
||||
if "super_admin" not in current_user.roles:
|
||||
target = await AdminUserService(db).get_user_by_id(id)
|
||||
if target and target.role == "super_admin":
|
||||
raise AppException(ErrorCode.FORBIDDEN, "无法重置超级管理员密码")
|
||||
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
await service.reset_password(id, body.new_password)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={"message": "密码重置成功"})
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"重置密码失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "重置密码失败")
|
||||
+84
-146
@@ -31,7 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_role
|
||||
from app.dependencies import get_current_user, require_role, dep_wecom_service
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
|
||||
from app.services.wecom_service import WecomService
|
||||
@@ -177,6 +177,8 @@ async def agent_login(
|
||||
# - 企微验证失败(用户不存在) → 拒绝登录
|
||||
# - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证
|
||||
wecom_verified = False
|
||||
# 默认空头像,企微验证成功时覆盖;确保在 wecom 不可达(降级)时仍可安全引用
|
||||
avatar = ""
|
||||
try:
|
||||
redis_client_verify = _get_redis()
|
||||
try:
|
||||
@@ -188,6 +190,14 @@ async def agent_login(
|
||||
real_name = user_info.get("name", "")
|
||||
if real_name:
|
||||
body.name = real_name
|
||||
# 【P1-02】每次坐席登录也强制更新头像(与 H5 登录保持一致,统一走 avatar_service)
|
||||
avatar = user_info.get("avatar", "")
|
||||
if avatar:
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client_verify, body.user_id, avatar)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步员工头像失败(不阻塞登录): user_id={body.user_id}, error={e}")
|
||||
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
|
||||
finally:
|
||||
try:
|
||||
@@ -258,15 +268,18 @@ async def agent_login(
|
||||
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
|
||||
|
||||
# 2. MFA 二次验证(已绑定 MFA 的坐席/管理员)
|
||||
# v1.5: 坐席和管理员都需要 OTP 验证
|
||||
# 决策3(三端认证重构 AUTH-04):移除「企微已登录+角色→免密直接进入」分支,
|
||||
# 所有登录方式(扫码/账密/企微验证)均需 OTP 验证,统一安全水位。
|
||||
# 执行MFA验证
|
||||
if agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
# 需要 OTP 验证,返回 require_otp 标记
|
||||
# 需要 OTP 验证,返回 require_otp 标记(必须包含role字段,否则前端校验会失败)
|
||||
return success_response(data={
|
||||
"require_otp": True,
|
||||
"message": "请输入OTP动态码",
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"role": agent.role, # 必须包含role字段,供前端校验权限
|
||||
})
|
||||
else:
|
||||
# 验证 OTP 码
|
||||
@@ -292,6 +305,7 @@ async def agent_login(
|
||||
employee_id=body.user_id,
|
||||
name=body.name,
|
||||
roles=roles,
|
||||
avatar=avatar,
|
||||
login_source="agent",
|
||||
)
|
||||
|
||||
@@ -398,148 +412,6 @@ async def list_agents(
|
||||
return success_response(data={"items": items})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OTP 绑定接口
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/agents/otp-bind")
|
||||
async def bind_agent_otp(
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""为当前坐席生成 OTP 密钥和二维码。
|
||||
|
||||
生成 TOTP 密钥,生成 otpauth:// URI 用于扫码绑定 Google Authenticator。
|
||||
返回二维码(base64编码)和密钥,供用户手动输入备用。
|
||||
|
||||
Returns:
|
||||
Dict: 二维码图片(base64)和密钥
|
||||
"""
|
||||
try:
|
||||
# v0.7.1: 用 mfa_secret 替代 otp_secret
|
||||
# 检查是否已绑定
|
||||
if agent.mfa_secret:
|
||||
# 已绑定,返回现有密钥的二维码
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
else:
|
||||
# 生成新密钥
|
||||
secret = pyotp.random_base32()
|
||||
agent.mfa_secret = secret
|
||||
# mfa_enabled 保持 False,等待首次验证后启用
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
totp = pyotp.TOTP(secret)
|
||||
|
||||
# 生成 otpauth:// URI
|
||||
otpauth_uri = totp.provisioning_uri(
|
||||
name=f"IT支持服务:{agent.name}",
|
||||
issuer_name="IT支持服务",
|
||||
)
|
||||
|
||||
# 生成二维码图片
|
||||
qr = qrcode.make(otpauth_uri)
|
||||
buffer = io.BytesIO()
|
||||
qr.save(buffer, format="PNG")
|
||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.mfa_secret[:4]}...")
|
||||
|
||||
return success_response(data={
|
||||
"qr_code": f"data:image/png;base64,{qr_base64}",
|
||||
"secret": agent.mfa_secret,
|
||||
})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"OTP绑定异常: {e}", exc_info=True)
|
||||
raise AppException(1007, f"OTP绑定失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/agents/otp-verify")
|
||||
async def verify_agent_otp(
|
||||
body: AgentLogin, # 复用 AgentLogin,otp_code 为必填
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""验证并启用 OTP。
|
||||
|
||||
用户输入 OTP 码验证成功后,启用 OTP。
|
||||
首次验证成功后 otp_enabled 设为 1。
|
||||
|
||||
Args:
|
||||
body.otp_code: 用户输入的 OTP 码(必填)
|
||||
|
||||
Returns:
|
||||
Dict: 验证结果
|
||||
"""
|
||||
try:
|
||||
# 查找坐席
|
||||
stmt = select(Agent).where(Agent.user_id == body.user_id)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent or not agent.mfa_secret:
|
||||
raise AppException(1008, "请先绑定OTP")
|
||||
|
||||
# 验证 OTP 码
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
if not totp.verify(body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误")
|
||||
|
||||
# 验证成功,启用 MFA
|
||||
agent.mfa_enabled = True
|
||||
agent.mfa_bound_at = datetime.now()
|
||||
agent.mfa_last_verified_at = datetime.now()
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={
|
||||
"mfa_enabled": True,
|
||||
"message": "OTP验证成功,已启用",
|
||||
})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"OTP验证异常: {e}", exc_info=True)
|
||||
raise AppException(1009, f"OTP验证失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/agents/otp-unbind")
|
||||
async def unbind_agent_otp(
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""解绑 OTP。
|
||||
|
||||
解绑后 mfa_secret 和 mfa_enabled 都清空。
|
||||
需要管理员操作。
|
||||
|
||||
Returns:
|
||||
Dict: 解绑结果
|
||||
"""
|
||||
try:
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
agent.mfa_last_verified_at = None
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"OTP解绑: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={"message": "OTP已解绑"})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"OTP解绑异常: {e}", exc_info=True)
|
||||
raise AppException(1010, f"OTP解绑失败: {str(e)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 本地密码管理接口(P0-#5)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -595,6 +467,72 @@ async def update_agent_password(
|
||||
raise AppException(1014, f"密码更新失败: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 忘记密码 - 企微扫码重置
|
||||
# ============================================================================
|
||||
|
||||
class AgentPasswordResetByWecom(BaseModel):
|
||||
"""通过企微扫码重置密码请求 Schema"""
|
||||
code: str = Field(..., description="企微OAuth2授权码")
|
||||
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
|
||||
|
||||
|
||||
@router.post("/agents/password/reset-by-wecom")
|
||||
async def reset_password_by_wecom(
|
||||
body: AgentPasswordResetByWecom,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wecom_service: WecomService = Depends(dep_wecom_service),
|
||||
):
|
||||
"""通过企微扫码验证后重置密码。
|
||||
|
||||
适用于坐席忘记原密码的情况。通过企微OAuth2扫码验证身份后,
|
||||
无需旧密码即可重置密码。
|
||||
|
||||
#91 新增端点。
|
||||
|
||||
Args:
|
||||
body.code: 企微OAuth2授权码
|
||||
body.new_password: 新密码(6-128位)
|
||||
|
||||
Returns:
|
||||
Dict: 重置结果
|
||||
"""
|
||||
try:
|
||||
# 1. 用 code 换取员工身份
|
||||
user_info = await wecom_service.get_oauth_user_info(body.code)
|
||||
employee_id = user_info.get("userid", "")
|
||||
|
||||
if not employee_id:
|
||||
raise AppException(2007, "OAuth2授权失败:未获取到员工ID")
|
||||
|
||||
# 2. 查询该员工是否是坐席
|
||||
from sqlalchemy import select
|
||||
from app.models.agent import Agent
|
||||
|
||||
stmt = select(Agent).where(Agent.user_id == employee_id)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalar_one_or_none()
|
||||
|
||||
if not agent:
|
||||
raise AppException(1015, "该员工不是坐席,无法重置密码")
|
||||
|
||||
# 3. 重置密码
|
||||
agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"密码已通过企微扫码重置: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={"message": "密码已重置"})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"密码重置异常: {e}", exc_info=True)
|
||||
raise AppException(1016, f"密码重置失败: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 企微 OAuth2 一键登录(坐席端)
|
||||
# ============================================================================
|
||||
@@ -749,5 +687,5 @@ async def oauth_callback(
|
||||
"user_id": agent.user_id,
|
||||
"name": employee_name or agent.name,
|
||||
"role": agent.role,
|
||||
"require_otp": agent.otp_secret is not None,
|
||||
"require_otp": agent.mfa_secret is not None,
|
||||
})
|
||||
|
||||
@@ -198,22 +198,46 @@ async def scan_qrcode(
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>扫码成功</title>
|
||||
<title>扫码成功 - IT智能服务台</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 20px; }}
|
||||
.card {{ background: #1e293b; border-radius: 16px; padding: 40px 32px; max-width: 400px; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }}
|
||||
h1 {{ color: #34d399; margin: 0 0 16px 0; font-size: 24px; }}
|
||||
p {{ color: #94a3b8; margin: 8px 0; line-height: 1.6; }}
|
||||
.ico {{ font-size: 56px; margin-bottom: 16px; }}
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }}
|
||||
.card {{ background: rgba(255,255,255,0.95); border-radius: 20px; padding: 48px 40px; max-width: 360px; width: 100%; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
|
||||
.logo {{ width: 80px; height: 80px; margin-bottom: 24px; }}
|
||||
.title {{ color: #1f2937; font-size: 28px; font-weight: 600; margin-bottom: 8px; }}
|
||||
.subtitle {{ color: #6b7280; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }}
|
||||
.status {{ display: inline-flex; align-items: center; gap: 8px; background: #dcfce7; color: #166534; padding: 12px 24px; border-radius: 50px; font-size: 14px; font-weight: 500; margin-bottom: 24px; }}
|
||||
.spinner {{ width: 20px; height: 20px; border: 2px solid #86efac; border-top-color: #166534; border-radius: 50%; animation: spin 1s linear infinite; }}
|
||||
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
|
||||
.tips {{ background: #f3f4f6; border-radius: 12px; padding: 16px; text-align: left; }}
|
||||
.tips-title {{ color: #374151; font-size: 13px; font-weight: 600; margin-bottom: 8px; }}
|
||||
.tips-item {{ color: #6b7280; font-size: 12px; line-height: 1.8; display: flex; align-items: flex-start; gap: 6px; }}
|
||||
.tips-item::before {{ content: '•'; color: #9ca3af; }}
|
||||
.footer {{ margin-top: 24px; color: #9ca3af; font-size: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="ico">✅</div>
|
||||
<h1>扫码成功</h1>
|
||||
<p>请在刚才打开登录页的浏览器中</p>
|
||||
<p>点击 <strong style="color:#60a5fa">「确认登录」</strong> 按钮完成登录</p>
|
||||
<p style="margin-top:24px;font-size:13px;color:#64748b">本页可关闭</p>
|
||||
<svg class="logo" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="80" height="80" rx="16" fill="#07C160"/>
|
||||
<path d="M24 40c0-8.837 7.163-16 16-16s16 7.163 16 16-7.163 16-16 16-16-7.163-16-16zm16 0c0 4.418 3.582 8 8 8s8-3.582 8-8-3.582-8-8-8-8-3.582-8-8z" fill="white"/>
|
||||
<path d="M24 28c0-6.627 5.373-12 12-12s12 5.373 12 12V40c0 6.627-5.373 12-12 12s-12-5.373-12-12z" fill="white" opacity="0.7"/>
|
||||
<circle cx="40" cy="48" r="4" fill="white"/>
|
||||
<rect x="36" y="54" width="8" height="12" rx="2" fill="white"/>
|
||||
</svg>
|
||||
<h1 class="title">扫码成功</h1>
|
||||
<div class="status">
|
||||
<span class="spinner"></span>
|
||||
等待确认登录...
|
||||
</div>
|
||||
<p class="subtitle">请在电脑端的登录页面点击<br><strong style="color:#07C160">「确认登录」</strong> 按钮完成登录</p>
|
||||
<div class="tips">
|
||||
<div class="tips-title">📋 操作指引</div>
|
||||
<div class="tips-item">已在电脑上打开登录页面</div>
|
||||
<div class="tips-item">点击页面上的「确认登录」按钮</div>
|
||||
<div class="tips-item">登录成功后可关闭此页面</div>
|
||||
</div>
|
||||
<div class="footer">IT智能服务台 · 税友集团</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -271,6 +295,21 @@ async def confirm_qrcode(
|
||||
otp_code=body.otp_code,
|
||||
)
|
||||
|
||||
# 同步头像:扫码时已从企微API拿到最新头像URL,这里落库 + 清缓存
|
||||
# (要求 A:确保所有登录路径刷新头像;头像更新失败不阻塞登录)
|
||||
confirm_avatar = result.get("avatar", "")
|
||||
if confirm_avatar:
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(
|
||||
db, redis_client, result["employee_id"], confirm_avatar
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"扫码确认同步头像失败(不阻塞): "
|
||||
f"employee_id={result.get('employee_id')}, error={e}"
|
||||
)
|
||||
|
||||
# 记录扫码登录日志(成功)
|
||||
from app.services.audit_log_service import record_audit_log
|
||||
await record_audit_log(
|
||||
|
||||
@@ -37,7 +37,7 @@ from app.models.role import Role
|
||||
from app.models.user_role import UserRole
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.services.audit_log_service import record_audit_log
|
||||
from app.utils.response import AppException
|
||||
from app.utils.response import AppException, success_response
|
||||
from app.dependencies import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -105,16 +105,18 @@ def _build_oauth_url(state: str, callback_url: str) -> str:
|
||||
@router.get("/sso/init")
|
||||
async def sso_init(
|
||||
request: Request,
|
||||
next: str = Query("/itdesk/", description="登录后跳转路径"),
|
||||
next: str = Query("/itagent/", description="登录后跳转路径"),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""初始化 SSO: 生成 state,302 跳转到企微 OAuth2 授权页。
|
||||
|
||||
支持任意浏览器环境,用户通过企微扫码授权后自动登录。
|
||||
|
||||
Args:
|
||||
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
||||
"""
|
||||
# 后端第二道防线:非企微环境拒绝授权
|
||||
_require_wework_ua(request)
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
# 用户通过企微扫码授权后即可自动登录
|
||||
|
||||
if not _sso_enabled():
|
||||
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
||||
@@ -160,7 +162,7 @@ def _get_error_redirect_url(error_code: str, error_msg: str, next_path: str = "/
|
||||
|
||||
# 重定向到 Portal 的 ErrorPage,带错误参数
|
||||
# ErrorPage 期望格式:?code=xxx&message=yyy
|
||||
return f"{base.rstrip('/')}/itportal/error?code={error_code}&message={urllib.parse.quote(error_msg)}"
|
||||
return f"{base.rstrip('/')}/itdesk/error?code={error_code}&message={urllib.parse.quote(error_msg)}"
|
||||
|
||||
|
||||
@router.get("/sso/callback")
|
||||
@@ -176,20 +178,19 @@ async def sso_callback(
|
||||
):
|
||||
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。
|
||||
|
||||
支持任意浏览器环境,用户通过企微扫码授权后自动登录。
|
||||
异常时重定向到前端错误页面,避免白屏。
|
||||
所有未处理的异常都会记录详细日志(包含 traceback)。
|
||||
|
||||
Args:
|
||||
next: 原始请求的目标路径,用于错误重定向。如果 state 验证失败,使用此参数决定重定向位置。
|
||||
"""
|
||||
import traceback
|
||||
|
||||
# 默认 next 路径
|
||||
next_path = next or "/itdesk/"
|
||||
# 默认 next 路径(坐席端)
|
||||
next_path = next or "/itagent/"
|
||||
|
||||
try:
|
||||
# 后端第二道防线:非企微环境拒绝回调
|
||||
_require_wework_ua(request)
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
|
||||
# 0. 处理企微返回的错误(用户拒绝授权等)
|
||||
if errcode is not None:
|
||||
@@ -247,6 +248,12 @@ async def sso_callback(
|
||||
|
||||
user_info = await wecom.get_user_info(user_id)
|
||||
name = user_info.get("name", user_id)
|
||||
# 同步头像到 employee 表 + 清缓存(要求 A;不阻塞登录)
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, user_id, user_info.get("avatar", ""))
|
||||
except Exception as av_err:
|
||||
logger.warning(f"SSO 同步头像失败(不阻塞): user_id={user_id}, error={av_err}")
|
||||
except Exception as e:
|
||||
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("api_failed", f"企业微信服务异常: {str(e)}"), status_code=302)
|
||||
@@ -349,10 +356,9 @@ async def sso_verify(
|
||||
):
|
||||
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。
|
||||
|
||||
后端第二道防线:非企微环境拒绝验证。
|
||||
支持任意浏览器环境。
|
||||
"""
|
||||
# 后端第二道防线:非企微环境拒绝验证
|
||||
_require_wework_ua(request)
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
|
||||
import json
|
||||
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
||||
@@ -363,10 +369,7 @@ async def sso_verify(
|
||||
await redis_client.delete(f"wecom_sso:token:{sso_token}")
|
||||
|
||||
payload = json.loads(token_raw.decode("utf-8"))
|
||||
return {
|
||||
"code": 0,
|
||||
"data": payload,
|
||||
}
|
||||
return success_response(data=payload)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
@@ -402,13 +405,7 @@ async def refresh_token(
|
||||
)
|
||||
|
||||
logger.info(f"Token 刷新成功: employee_id={user_info.get('employee_id')}")
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": token, # 复用同一个 token,只延长 TTL
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
return success_response(data={"token": token, "expires_in": TOKEN_TTL_SECONDS})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -526,3 +523,179 @@ async def refresh_token_alias(
|
||||
|
||||
logger.warning(f"Token 刷新失败(alias): token 不存在或已过期")
|
||||
raise AppException(401, "Token 已过期,请重新登录")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_wecom/jsdk-login — 企微 JS-SDK 免认证登录 (v1.8 新增)
|
||||
# --------------------------------------------------------------------------
|
||||
# 流程:
|
||||
# 1. 前端通过 wx.agentConfig 获取企微用户 userid
|
||||
# 2. 前端调用本接口,传入 userid
|
||||
# 3. 后端验证 userid 是否是坐席
|
||||
# 4. 生成 token,返回给前端
|
||||
# --------------------------------------------------------------------------
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WecomJsdkLoginRequest(BaseModel):
|
||||
"""企微 JS-SDK 免认证登录请求"""
|
||||
userid: str = Field(..., description="企微用户 ID(从 wx.agentConfig 获取)")
|
||||
login_source: str = Field(default="wecom_jsdk", description="登录来源标识")
|
||||
|
||||
|
||||
@router.post("/jsdk-login")
|
||||
async def wecom_jsdk_login(
|
||||
body: WecomJsdkLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""企微 JS-SDK 免认证登录。
|
||||
|
||||
前端通过企微 JS-SDK (wx.agentConfig) 获取当前用户 ID,
|
||||
然后调用本接口进行免认证登录。
|
||||
|
||||
流程:
|
||||
1. 调用 /wecom/check-role 接口验证 userid 是否是坐席
|
||||
2. 查找或创建坐席记录
|
||||
3. 生成 token,存入 Redis
|
||||
4. 返回 token 和坐席信息
|
||||
|
||||
Args:
|
||||
body: 包含企微 userid
|
||||
db: 数据库会话
|
||||
redis_client: Redis 客户端
|
||||
|
||||
Returns:
|
||||
登录成功:{ code: 0, data: { token, employee_id, name, roles } }
|
||||
"""
|
||||
try:
|
||||
# 1. 验证用户是否具有坐席或管理员角色
|
||||
wecom_service = WecomService(redis_client)
|
||||
userid = body.userid
|
||||
|
||||
# 从数据库查询用户角色(支持坐席和管理员)
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
role_service = RoleMappingService(db)
|
||||
user_roles = await role_service.get_user_roles(userid)
|
||||
|
||||
# 检查是否具有坐席或管理员角色
|
||||
has_agent_role = "agent" in user_roles
|
||||
has_admin_role = "admin" in user_roles
|
||||
|
||||
if not has_agent_role and not has_admin_role:
|
||||
# 无坐席或管理员角色,尝试从企微标签检测(兼容旧逻辑)
|
||||
tag_id = getattr(settings, "wecom_agent_tag_id", None)
|
||||
if tag_id:
|
||||
try:
|
||||
access_token = await wecom_service.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/tag/get?access_token={access_token}&tagid={tag_id}"
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode", 0) == 0:
|
||||
user_list = result.get("userlist", [])
|
||||
user_ids = [
|
||||
u if isinstance(u, str) else u.get("userid", "")
|
||||
for u in user_list
|
||||
]
|
||||
if userid in user_ids:
|
||||
has_agent_role = True
|
||||
logger.info(f"企微 JS-SDK 免认证: userid={userid} 通过企微标签验证为坐席")
|
||||
except Exception as e:
|
||||
logger.warning(f"企微标签检测失败: {e}")
|
||||
|
||||
# 如果既没有坐席也没有管理员角色,拒绝登录
|
||||
if not has_agent_role and not has_admin_role:
|
||||
logger.warning(f"企微 JS-SDK 免认证失败: userid={userid} 没有坐席或管理员角色")
|
||||
raise AppException(403, "您没有坐席或管理员权限,无法使用此方式登录")
|
||||
|
||||
# 确定用户角色
|
||||
role_names = []
|
||||
if has_admin_role:
|
||||
role_names.append("admin")
|
||||
if has_agent_role:
|
||||
role_names.append("agent")
|
||||
|
||||
logger.info(f"企微 JS-SDK 免认证: userid={userid}, roles={role_names}")
|
||||
|
||||
# 3. 获取用户详细信息
|
||||
try:
|
||||
user_info = await wecom_service.get_user_info(userid)
|
||||
user_name = user_info.get("name", userid)
|
||||
# 同步头像到 employee 表 + 清缓存(要求 A;不阻塞登录)
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, userid, user_info.get("avatar", ""))
|
||||
except Exception as av_err:
|
||||
logger.warning(f"JS-SDK 同步头像失败(不阻塞): userid={userid}, error={av_err}")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取企微用户信息失败: {e}")
|
||||
user_name = userid
|
||||
|
||||
# 4. 查找或创建坐席记录
|
||||
from app.models.agent import Agent
|
||||
|
||||
stmt = select(Agent).where(Agent.user_id == userid)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
# 首次登录,创建坐席记录
|
||||
agent = Agent(
|
||||
user_id=userid,
|
||||
name=user_name,
|
||||
status="online",
|
||||
current_load=0,
|
||||
max_load=5,
|
||||
)
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
logger.info(f"企微 JS-SDK 免认证创建坐席: user_id={userid}, name={user_name}")
|
||||
else:
|
||||
# 更新坐席状态
|
||||
agent.name = user_name
|
||||
agent.status = "online"
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
logger.info(f"企微 JS-SDK 免认证登录: user_id={userid}, name={user_name}")
|
||||
|
||||
# 5. 生成 token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# 6. 存储 token 到 Redis
|
||||
token_key = f"agent:token:{token}"
|
||||
await redis_client.setex(token_key, TOKEN_TTL_SECONDS, userid)
|
||||
|
||||
# 7. 记录审计日志
|
||||
await record_audit_log(
|
||||
db=db,
|
||||
employee_id=userid,
|
||||
action="wecom_jsdk_login",
|
||||
resource="auth",
|
||||
resource_id=userid,
|
||||
details={
|
||||
"name": user_name,
|
||||
"roles": role_names,
|
||||
"login_method": "wecom_jsdk",
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"企微 JS-SDK 免认证登录成功: user_id={userid}, roles={role_names}")
|
||||
|
||||
return success_response(data={
|
||||
"token": token,
|
||||
"employee_id": userid,
|
||||
"name": user_name,
|
||||
"roles": role_names,
|
||||
})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"企微 JS-SDK 免认证登录异常: {e}", exc_info=True)
|
||||
raise AppException(500, f"免认证登录失败: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化闭环 API
|
||||
# =============================================================================
|
||||
# 说明:提供自动化会话的 REST 接口与专用 WebSocket 通道。
|
||||
# 前缀(经 Vite/ nginx 剥离 /api 后):/itportal/automation
|
||||
#
|
||||
# 坐席端(agent):
|
||||
# POST /itportal/automation/sessions — 创建并启动会话
|
||||
# GET /itportal/automation/sessions — 会话列表
|
||||
# GET /itportal/automation/sessions/{id} — 会话详情
|
||||
# POST /itportal/automation/sessions/{id}/approve — 坐席审批/驳回
|
||||
# POST /itportal/automation/sessions/{id}/takeover — 转人工接管
|
||||
#
|
||||
# 员工端(H5):
|
||||
# POST /itportal/automation/sessions/by-employee — 员工创建会话
|
||||
# GET /itportal/automation/sessions/{id}/employee — 员工查看详情
|
||||
# POST /itportal/automation/sessions/{id}/confirm — 员工 H5 二次确认
|
||||
# POST /itportal/automation/sessions/{id}/feedback — 员工结果反馈
|
||||
#
|
||||
# 管理端(admin,配置写需 OTP):
|
||||
# GET /itportal/automation/admin/scenarios — 场景配置列表
|
||||
# PUT /itportal/automation/admin/scenarios/{key} — 更新场景配置(OTP)
|
||||
# GET /itportal/automation/admin/rule-versions — 规则版本列表
|
||||
# GET /itportal/automation/admin/metrics — 看板指标
|
||||
#
|
||||
# WebSocket:
|
||||
# /ws/automation/{session_id} — 自动化进度/审批/确认实时推送
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_high_risk_otp
|
||||
from app.dependencies.automation import get_current_employee_id
|
||||
from app.api.agents import get_current_agent
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.automation import (
|
||||
CreateSessionRequest,
|
||||
ResolutionFeedbackRequest,
|
||||
ScenarioConfigResponse,
|
||||
ScenarioConfigUpdate,
|
||||
SessionResponse,
|
||||
ApprovalDecisionRequest,
|
||||
ConfirmRequest,
|
||||
RuleVersionResponse,
|
||||
ResolveFeedbackRequest,
|
||||
TakeoverRequest,
|
||||
AutoMetricsResponse,
|
||||
serialize_action,
|
||||
serialize_approval,
|
||||
serialize_session,
|
||||
)
|
||||
from app.services.automation import (
|
||||
ActionExecutor,
|
||||
AutoSessionService,
|
||||
AutomationException,
|
||||
to_app_exception,
|
||||
)
|
||||
from app.services.automation.progress_publisher import (
|
||||
register_ws,
|
||||
set_parties,
|
||||
unregister_ws,
|
||||
)
|
||||
from app.services.cache_service import cache_service
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# REST 路由器(前缀 /itportal/automation,经 /api 代理剥离)
|
||||
# --------------------------------------------------------------------------
|
||||
router = APIRouter(prefix="/itportal/automation")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WebSocket 路由器(根路径 /ws/automation/{session_id})
|
||||
# --------------------------------------------------------------------------
|
||||
ws_router = APIRouter()
|
||||
|
||||
# WS 认证失败关闭码(与 ws.py 保持一致)
|
||||
WS_CLOSE_UNAUTHORIZED = 4001
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 坐席端接口
|
||||
# ==========================================================================
|
||||
@router.post("/sessions", tags=["自动化闭环"])
|
||||
async def create_session(
|
||||
req: CreateSessionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席创建自动化会话并启动后台编排。"""
|
||||
svc = AutoSessionService(db)
|
||||
session = await svc.create_session(
|
||||
conversation_id=req.conversation_id,
|
||||
employee_id=req.employee_id,
|
||||
description=req.description,
|
||||
mode=req.mode,
|
||||
)
|
||||
await db.flush()
|
||||
# 记录参与方,供进度兜底推送
|
||||
set_parties(session.id, employee_id=req.employee_id)
|
||||
await db.commit()
|
||||
|
||||
# 后台运行编排(不阻塞响应)
|
||||
asyncio.create_task(_run_background(session.id))
|
||||
|
||||
data = serialize_session(session)
|
||||
return success_response(data.model_dump() if hasattr(data, "model_dump") else data.__dict__)
|
||||
|
||||
|
||||
@router.get("/sessions", tags=["自动化闭环"])
|
||||
async def list_sessions(
|
||||
employee_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席查看自动化会话列表。"""
|
||||
svc = AutoSessionService(db)
|
||||
sessions = await svc.list_sessions(
|
||||
employee_id=employee_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return success_response([_session_min(s) for s in sessions])
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", tags=["自动化闭环"])
|
||||
async def get_session(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席查看会话详情。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
return success_response(_detail_payload(detail))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/approve", tags=["自动化闭环"])
|
||||
async def approve_session(
|
||||
session_id: str,
|
||||
req: ApprovalDecisionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席审批/驳回当前待决高危动作。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
action_id = detail["session"].current_action_id
|
||||
if not action_id or detail["ticket"] is None:
|
||||
raise AppException(4004, "当前没有待审批的动作")
|
||||
executor = ActionExecutor(db)
|
||||
try:
|
||||
await executor.resume(
|
||||
session_id,
|
||||
action_id,
|
||||
decision=req.decision,
|
||||
note=req.note,
|
||||
approver_id=current_agent.user_id,
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/takeover", tags=["自动化闭环"])
|
||||
async def takeover_session(
|
||||
session_id: str,
|
||||
req: TakeoverRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席转人工接管会话。"""
|
||||
svc = AutoSessionService(db)
|
||||
try:
|
||||
session = await svc.takeover(
|
||||
session_id, agent_id=current_agent.user_id, note=req.note
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(serialize_session(session).__dict__)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 员工端(H5)接口
|
||||
# ==========================================================================
|
||||
@router.post("/sessions/by-employee", tags=["自动化闭环"])
|
||||
async def create_session_by_employee(
|
||||
req: CreateSessionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工(H5)创建自动化会话。"""
|
||||
svc = AutoSessionService(db)
|
||||
session = await svc.create_session(
|
||||
conversation_id=req.conversation_id,
|
||||
employee_id=employee_id,
|
||||
description=req.description,
|
||||
mode=req.mode,
|
||||
)
|
||||
await db.flush()
|
||||
set_parties(session.id, employee_id=employee_id)
|
||||
await db.commit()
|
||||
asyncio.create_task(_run_background(session.id))
|
||||
return success_response(serialize_session(session).__dict__)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/employee", tags=["自动化闭环"])
|
||||
async def get_session_employee(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工(H5)查看自己会话详情。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None or detail["session"].employee_id != employee_id:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
return success_response(_detail_payload(detail))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/confirm", tags=["自动化闭环"])
|
||||
async def confirm_session(
|
||||
session_id: str,
|
||||
req: ConfirmRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工 H5 二次确认(高危写操作)。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None or detail["session"].employee_id != employee_id:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
action_id = detail["session"].current_action_id
|
||||
if not action_id or detail["ticket"] is None:
|
||||
raise AppException(4004, "当前没有待确认的动作")
|
||||
if detail["ticket"].channel != "h5":
|
||||
raise AppException(4004, "该动作需坐席审批,员工无需确认")
|
||||
executor = ActionExecutor(db)
|
||||
try:
|
||||
await executor.resume(
|
||||
session_id,
|
||||
action_id,
|
||||
decision="approve" if req.confirmed else "reject",
|
||||
note=req.note,
|
||||
approver_id=employee_id,
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/feedback", tags=["自动化闭环"])
|
||||
async def feedback_session(
|
||||
session_id: str,
|
||||
req: ResolveFeedbackRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工对处置结果反馈(满意→关单 / 不满意→转人工)。"""
|
||||
svc = AutoSessionService(db)
|
||||
try:
|
||||
session = await svc.resolve_feedback(
|
||||
session_id, satisfied=req.satisfied, note=req.note
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(serialize_session(session).__dict__)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 管理端接口(配置写需 OTP)
|
||||
# ==========================================================================
|
||||
@router.get("/admin/scenarios", tags=["自动化闭环-管理"])
|
||||
async def list_scenarios(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""场景配置列表(只读,无需 OTP)。"""
|
||||
svc = AutoSessionService(db)
|
||||
configs = await svc.list_scenario_configs()
|
||||
return success_response([_scenario_payload(c) for c in configs])
|
||||
|
||||
|
||||
@router.put("/admin/scenarios/{scenario_key}", tags=["自动化闭环-管理"])
|
||||
async def update_scenario(
|
||||
scenario_key: str,
|
||||
req: ScenarioConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_otp: object = Depends(require_high_risk_otp),
|
||||
):
|
||||
"""更新场景配置(高危写操作,需 OTP)。"""
|
||||
svc = AutoSessionService(db)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
config = await svc.upsert_scenario_config(scenario_key, data, operator="admin")
|
||||
await db.commit()
|
||||
return success_response(_scenario_payload(config))
|
||||
|
||||
|
||||
@router.get("/admin/rule-versions", tags=["自动化闭环-管理"])
|
||||
async def list_rule_versions(
|
||||
scenario_key: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""规则版本列表。"""
|
||||
svc = AutoSessionService(db)
|
||||
versions = await svc.list_rule_versions(scenario_key=scenario_key)
|
||||
return success_response([_rule_version_payload(v) for v in versions])
|
||||
|
||||
|
||||
@router.get("/admin/metrics", tags=["自动化闭环-管理"])
|
||||
async def get_metrics(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""自动化看板指标。"""
|
||||
svc = AutoSessionService(db)
|
||||
metrics = await svc.metrics()
|
||||
return success_response(metrics)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# WebSocket:自动化进度专用通道
|
||||
# ==========================================================================
|
||||
@ws_router.websocket("/ws/automation/{session_id}")
|
||||
async def automation_ws_endpoint(websocket: WebSocket, session_id: str) -> None:
|
||||
"""自动化会话专用 WebSocket(坐席/员工均可连)。
|
||||
|
||||
认证:优先 subprotocol bearer.{token},其次 Authorization header,
|
||||
最后 query ?token=。token 需在 agent:token / employee:token 中存在。
|
||||
"""
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||
if subprotocol.startswith("bearer."):
|
||||
token = subprotocol[7:]
|
||||
else:
|
||||
auth_header = websocket.headers.get("Authorization", "")
|
||||
token = auth_header[7:] if auth_header.startswith("Bearer ") else websocket.query_params.get("token", "")
|
||||
|
||||
if not token:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
|
||||
return
|
||||
|
||||
# 校验 token(坐席或员工任一即可)
|
||||
try:
|
||||
aid = await cache_service.get(f"agent:token:{token}")
|
||||
eid = await cache_service.get(f"employee:token:{token}") if not aid else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"自动化 WS token 校验失败: {e}")
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Auth unavailable")
|
||||
return
|
||||
|
||||
if not aid and not eid:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid token")
|
||||
return
|
||||
|
||||
register_ws(session_id, websocket)
|
||||
logger.info(f"自动化 WS 连接: session={session_id}")
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
if data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
except WebSocketDisconnect:
|
||||
unregister_ws(session_id, websocket)
|
||||
logger.info(f"自动化 WS 断开: session={session_id}")
|
||||
except Exception: # noqa: BLE001
|
||||
unregister_ws(session_id, websocket)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 辅助函数
|
||||
# ==========================================================================
|
||||
async def _run_background(session_id: str) -> None:
|
||||
"""后台运行编排(独立导入避免循环依赖)。"""
|
||||
from app.services.automation import run_session_in_background
|
||||
|
||||
await run_session_in_background(session_id)
|
||||
|
||||
|
||||
def _session_min(session) -> dict:
|
||||
"""会话列表最小字段。"""
|
||||
return {
|
||||
"id": session.id,
|
||||
"employee_id": session.employee_id,
|
||||
"scenario_key": session.scenario_key,
|
||||
"status": session.status,
|
||||
"mode": session.mode,
|
||||
"confidence": session.confidence,
|
||||
"title": session.title,
|
||||
"created_at": session.created_at.isoformat() if session.created_at else None,
|
||||
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _detail_payload(detail: dict) -> dict:
|
||||
"""构造会话详情响应 data。"""
|
||||
session = detail["session"]
|
||||
actions = detail.get("actions", [])
|
||||
ticket = detail.get("ticket")
|
||||
return serialize_session(session, actions=actions, ticket=ticket).__dict__
|
||||
|
||||
|
||||
def _scenario_payload(config) -> dict:
|
||||
"""场景配置响应。"""
|
||||
return ScenarioConfigResponse(
|
||||
id=config.id,
|
||||
scenario_key=config.scenario_key,
|
||||
name=config.name,
|
||||
description=config.description,
|
||||
enabled=config.enabled,
|
||||
trigger_conditions=config.trigger_conditions,
|
||||
actions=config.actions,
|
||||
approval_strategy=config.approval_strategy,
|
||||
current_version_id=config.current_version_id,
|
||||
).model_dump()
|
||||
|
||||
|
||||
def _rule_version_payload(version) -> dict:
|
||||
"""规则版本响应。"""
|
||||
return RuleVersionResponse(
|
||||
id=version.id,
|
||||
scenario_key=version.scenario_key,
|
||||
version=version.version,
|
||||
content=version.content,
|
||||
status=version.status,
|
||||
canary_percent=version.canary_percent,
|
||||
created_by=version.created_by,
|
||||
remark=version.remark,
|
||||
created_at=version.created_at.isoformat() if version.created_at else None,
|
||||
).model_dump()
|
||||
@@ -0,0 +1,99 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 会话标注 API
|
||||
# =============================================================================
|
||||
# 说明:会话标注接口
|
||||
# 1. POST /api/annotations — 创建标注
|
||||
# 2. GET /api/annotations/{conversation_id} — 获取会话的标注列表
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation_annotation import ConversationAnnotation
|
||||
from app.schemas.conversation_annotation import (
|
||||
AnnotationCreate,
|
||||
AnnotationResponse,
|
||||
)
|
||||
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
|
||||
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/annotations — 创建标注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/annotations")
|
||||
async def create_annotation(
|
||||
body: AnnotationCreate,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建会话标注。
|
||||
|
||||
坐席对AI回复进行标注(有用/无用)。
|
||||
|
||||
Args:
|
||||
body: 创建请求体
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含创建的标注
|
||||
"""
|
||||
annotation = ConversationAnnotation(
|
||||
conversation_id=body.conversation_id,
|
||||
agent_id=agent.id,
|
||||
message_id=body.message_id,
|
||||
feedback=body.feedback,
|
||||
comment=body.comment,
|
||||
)
|
||||
db.add(annotation)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"创建会话标注: conversation={body.conversation_id}, feedback={body.feedback}")
|
||||
|
||||
data = AnnotationResponse.model_validate(annotation).model_dump()
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/annotations/{conversation_id} — 获取会话的标注列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/annotations/{conversation_id}")
|
||||
async def list_annotations(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话的所有标注。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含标注列表
|
||||
"""
|
||||
stmt = (
|
||||
select(ConversationAnnotation)
|
||||
.where(ConversationAnnotation.conversation_id == conversation_id)
|
||||
.order_by(ConversationAnnotation.created_at.desc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
annotations = list(result.scalars().all())
|
||||
|
||||
data = [AnnotationResponse.model_validate(a).model_dump() for a in annotations]
|
||||
return success_response(data={"items": data})
|
||||
@@ -176,6 +176,29 @@ async def get_conversation(
|
||||
session_service = SessionService(db, redis_client=redis)
|
||||
conversation = await session_service.get_conversation(conversation_id)
|
||||
|
||||
# 如果会话中员工姓名为空,从 employees 表回退获取
|
||||
if not conversation.employee_name:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.employee import Employee
|
||||
stmt = select(Employee).where(Employee.employee_id == conversation.employee_id)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee and employee.name:
|
||||
conversation.employee_name = employee.name
|
||||
conversation.department = employee.department or ""
|
||||
conversation.position = employee.position or ""
|
||||
conversation.level = employee.level or ""
|
||||
logger.info(
|
||||
f"从employees表回退获取会话详情员工信息: employee_id={conversation.employee_id}, "
|
||||
f"name={employee.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"从employees表获取会话详情员工信息失败: employee_id={conversation.employee_id}, "
|
||||
f"error={e}"
|
||||
)
|
||||
|
||||
# 获取员工头像(带缓存)
|
||||
avatar = await session_service._get_employee_avatar(conversation.employee_id)
|
||||
|
||||
@@ -214,14 +237,18 @@ async def assign_conversation(
|
||||
redis_client = settings.create_redis_client()
|
||||
wecom_service = WecomService(redis_client)
|
||||
session_service = SessionService(db, wecom_service=wecom_service)
|
||||
except Exception:
|
||||
logger.warning("创建企微服务失败,接入通知将不发送")
|
||||
except Exception as e:
|
||||
logger.warning(f"创建企微服务失败: {e},接入通知将不发送")
|
||||
session_service = SessionService(db)
|
||||
|
||||
conversation = await session_service.assign_agent(
|
||||
conversation_id=conversation_id,
|
||||
agent_id=body.agent_id,
|
||||
)
|
||||
try:
|
||||
conversation = await session_service.assign_agent(
|
||||
conversation_id=conversation_id,
|
||||
agent_id=body.agent_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"接单失败: conversation_id={conversation_id}, agent_id={body.agent_id}, error={e}")
|
||||
raise
|
||||
|
||||
# 关闭企微服务连接
|
||||
if redis_client:
|
||||
|
||||
+56
-29
@@ -12,14 +12,19 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_redis
|
||||
from app.models.employee import Employee
|
||||
from app.services.token_service import TokenService
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,6 +72,7 @@ async def dev_login(
|
||||
department: str = Query("信息技术部", description="部门"),
|
||||
avatar: Optional[str] = Query(None, description="头像 URL(可选)"),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""开发模式 Mock 登录。
|
||||
|
||||
@@ -105,23 +111,51 @@ async def dev_login(
|
||||
login_source="dev",
|
||||
)
|
||||
|
||||
# Mock 登录时同步写入 employees 表(便于坐席端显示员工姓名)
|
||||
from sqlalchemy import select
|
||||
stmt = select(Employee).where(Employee.employee_id == userid)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee:
|
||||
# 更新已有记录
|
||||
employee.name = name
|
||||
employee.department = department
|
||||
employee.avatar = avatar or ""
|
||||
employee.avatar_updated_at = datetime.utcnow()
|
||||
else:
|
||||
# 创建新记录
|
||||
employee = Employee(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
employee_id=userid,
|
||||
name=name,
|
||||
department=department,
|
||||
position="",
|
||||
avatar=avatar or "",
|
||||
avatar_updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(employee)
|
||||
# 清 Redis 头像缓存,确保下次读取数据库最新头像(要求 C:清缓存 + 更新时间一致)
|
||||
if avatar:
|
||||
try:
|
||||
await redis.delete(f"employee:avatar:{userid}")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除头像Redis缓存失败: userid={userid}, error={e}")
|
||||
await db.commit()
|
||||
logger.info(f"🧪 [DEV] 同步员工信息到 employees 表: userid={userid}, name={name}")
|
||||
|
||||
logger.info(f"🧪 [DEV] Mock 登录成功: userid={userid}, roles={roles}")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
"userid": userid,
|
||||
"name": name,
|
||||
"department": department,
|
||||
"avatar": avatar or "",
|
||||
"roles": roles,
|
||||
"login_source": "dev",
|
||||
},
|
||||
return success_response(data={
|
||||
"token": token,
|
||||
"user": {
|
||||
"userid": userid,
|
||||
"name": name,
|
||||
"department": department,
|
||||
"avatar": avatar or "",
|
||||
"roles": roles,
|
||||
"login_source": "dev",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -133,11 +167,7 @@ async def dev_list_users():
|
||||
if not _dev_mode_enabled():
|
||||
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": PRESET_DEV_USERS,
|
||||
}
|
||||
return success_response(data=PRESET_DEV_USERS)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -149,13 +179,10 @@ async def dev_health():
|
||||
if not _dev_mode_enabled():
|
||||
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"dev_mode": True,
|
||||
"env": os.getenv("APP_ENV", "unknown"),
|
||||
"database_url": os.getenv("DATABASE_URL", "not set")[:50] + "...",
|
||||
"redis_url": os.getenv("REDIS_URL", "not set"),
|
||||
"preset_users": len(PRESET_DEV_USERS),
|
||||
},
|
||||
}
|
||||
return success_response(data={
|
||||
"dev_mode": True,
|
||||
"env": os.getenv("APP_ENV", "unknown"),
|
||||
"database_url": os.getenv("DATABASE_URL", "not set")[:50] + "...",
|
||||
"redis_url": os.getenv("REDIS_URL", "not set"),
|
||||
"preset_users": len(PRESET_DEV_USERS),
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ 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.config import settings
|
||||
from app.models.employee import Employee
|
||||
from app.dependencies import dep_redis
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 满意度评价 API
|
||||
# =============================================================================
|
||||
# 说明:满意度评价相关接口
|
||||
# 1. POST /api/conversation/{conversation_id}/evaluate - 提交评价
|
||||
# 2. GET /api/conversation/{conversation_id}/evaluation - 获取会话评价
|
||||
# 3. GET /api/evaluations/stats - 获取评价统计(管理后台)
|
||||
# 4. POST /api/conversations/{id}/send-evaluation-invite - 发送评价邀请(坐席端触发)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.conversation_evaluation import ConversationEvaluation
|
||||
from app.schemas.evaluation import (
|
||||
EvaluationInviteRequest,
|
||||
EvaluationStatsItem,
|
||||
EvaluationStatsResponse,
|
||||
EvaluationSubmitRequest,
|
||||
EvaluationResponse,
|
||||
)
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
# H5认证依赖(从 h5.py 导入)
|
||||
from app.api.h5 import _get_current_employee
|
||||
from app.models.employee import Employee
|
||||
|
||||
# 坐席认证依赖(从 agents.py 导入)
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
# RBAC 权限装饰器
|
||||
from app.dependencies import UserInfo, get_current_user, get_redis, require_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 表情标签映射
|
||||
# --------------------------------------------------------------------------
|
||||
EMOJI_LABELS = {
|
||||
"satisfied": "满意",
|
||||
"neutral": "一般",
|
||||
"dissatisfied": "不满意",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversation/{conversation_id}/evaluate - 提交评价
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversation/{conversation_id}/evaluate")
|
||||
async def submit_evaluation(
|
||||
conversation_id: str,
|
||||
body: EvaluationSubmitRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
):
|
||||
"""提交满意度评价。
|
||||
|
||||
员工对已结束的会话进行满意度评价。
|
||||
评价要素:星级(1-5)、表情(satisfied/neutral/dissatisfied)、文字反馈(可选)。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 评价请求体
|
||||
db: 数据库会话
|
||||
employee_id: 当前员工ID(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含评价记录
|
||||
"""
|
||||
# 1. 获取员工姓名
|
||||
emp_stmt = select(Employee).where(Employee.employee_id == employee_id)
|
||||
emp_result = await db.execute(emp_stmt)
|
||||
employee = emp_result.scalars().first()
|
||||
employee_name = employee.name if employee else ""
|
||||
|
||||
# 2. 验证会话存在且已结单
|
||||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(3003, "会话不存在")
|
||||
if conversation.status != "resolved":
|
||||
raise AppException(3040, "只能评价已结单的会话")
|
||||
|
||||
# 3. 检查是否已评价(防止重复评价)
|
||||
existing_stmt = select(ConversationEvaluation).where(
|
||||
ConversationEvaluation.conversation_id == conversation_id,
|
||||
ConversationEvaluation.employee_id == employee_id,
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing = existing_result.scalars().first()
|
||||
|
||||
if existing:
|
||||
raise AppException(3041, "您已对该会话提交过评价")
|
||||
|
||||
# 4. 创建评价记录
|
||||
evaluation = ConversationEvaluation(
|
||||
id=None, # UUID自动生成
|
||||
conversation_id=conversation_id,
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
star_rating=body.star_rating,
|
||||
emoji=body.emoji,
|
||||
feedback_text=body.feedback_text,
|
||||
)
|
||||
db.add(evaluation)
|
||||
await db.commit()
|
||||
await db.refresh(evaluation)
|
||||
|
||||
logger.info(
|
||||
f"员工 {employee_name} 提交评价: "
|
||||
f"会话={conversation_id}, 星级={body.star_rating}, 表情={body.emoji}"
|
||||
)
|
||||
|
||||
response_data = EvaluationResponse.model_validate(evaluation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/conversation/{conversation_id}/evaluation - 获取会话评价
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversation/{conversation_id}/evaluation")
|
||||
async def get_evaluation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话的评价记录。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含评价记录(如果已评价)
|
||||
"""
|
||||
stmt = select(ConversationEvaluation).where(
|
||||
ConversationEvaluation.conversation_id == conversation_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
evaluation = result.scalars().first()
|
||||
|
||||
if not evaluation:
|
||||
return success_response(data=None)
|
||||
|
||||
response_data = EvaluationResponse.model_validate(evaluation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/evaluations/stats - 获取评价统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/evaluations/stats")
|
||||
@require_permission("evaluation", "read", "all")
|
||||
async def get_evaluation_stats(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""获取满意度评价统计数据。
|
||||
|
||||
供管理后台查看评价统计信息,包括:
|
||||
- 总评价数
|
||||
- 平均星级
|
||||
- 星级分布
|
||||
- 表情分布
|
||||
- 最近评价记录
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含统计数据
|
||||
"""
|
||||
# 1. 获取总评价数
|
||||
total_stmt = select(func.count(ConversationEvaluation.id))
|
||||
total_result = await db.execute(total_stmt)
|
||||
total_count = total_result.scalar() or 0
|
||||
|
||||
# 2. 获取平均星级
|
||||
avg_stmt = select(func.avg(ConversationEvaluation.star_rating))
|
||||
avg_result = await db.execute(avg_stmt)
|
||||
avg_star_rating = float(avg_result.scalar() or 0)
|
||||
|
||||
# 3. 星级分布统计
|
||||
star_dist_stmt = select(
|
||||
ConversationEvaluation.star_rating,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).group_by(ConversationEvaluation.star_rating)
|
||||
star_dist_result = await db.execute(star_dist_stmt)
|
||||
star_rows = star_dist_result.all()
|
||||
|
||||
star_distribution = []
|
||||
for star in range(1, 6):
|
||||
count = next((row.count for row in star_rows if row.star_rating == star), 0)
|
||||
percentage = (count / total_count * 100) if total_count > 0 else 0
|
||||
star_distribution.append(
|
||||
EvaluationStatsItem(
|
||||
label=f"{star}星",
|
||||
count=count,
|
||||
percentage=round(percentage, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 表情分布统计
|
||||
emoji_dist_stmt = select(
|
||||
ConversationEvaluation.emoji,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).group_by(ConversationEvaluation.emoji)
|
||||
emoji_dist_result = await db.execute(emoji_dist_stmt)
|
||||
emoji_rows = emoji_dist_result.all()
|
||||
|
||||
emoji_distribution = []
|
||||
for emoji_key in ["satisfied", "neutral", "dissatisfied"]:
|
||||
count = next((row.count for row in emoji_rows if row.emoji == emoji_key), 0)
|
||||
percentage = (count / total_count * 100) if total_count > 0 else 0
|
||||
emoji_distribution.append(
|
||||
EvaluationStatsItem(
|
||||
label=EMOJI_LABELS.get(emoji_key, emoji_key),
|
||||
count=count,
|
||||
percentage=round(percentage, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 最近评价记录
|
||||
recent_stmt = (
|
||||
select(ConversationEvaluation)
|
||||
.order_by(ConversationEvaluation.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
recent_result = await db.execute(recent_stmt)
|
||||
recent_evaluations = recent_result.scalars().all()
|
||||
|
||||
recent_list = [
|
||||
EvaluationResponse.model_validate(e).model_dump()
|
||||
for e in recent_evaluations
|
||||
]
|
||||
|
||||
response_data = EvaluationStatsResponse(
|
||||
total_count=total_count,
|
||||
avg_star_rating=round(avg_star_rating, 2),
|
||||
star_distribution=star_distribution,
|
||||
emoji_distribution=emoji_distribution,
|
||||
recent_evaluations=recent_list,
|
||||
).model_dump()
|
||||
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/send-evaluation-invite - 发送评价邀请
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/send-evaluation-invite")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def send_evaluation_invite(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""发送评价邀请(坐席结单后触发)。
|
||||
|
||||
坐席点击"结单"后,系统自动向员工推送评价邀请消息。
|
||||
员工点击消息中的链接可进入H5页面提交评价。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
redis: Redis连接
|
||||
current_agent: 当前坐席
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 1. 验证会话存在
|
||||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(3003, "会话不存在")
|
||||
|
||||
# 2. 验证会话已结单
|
||||
if conversation.status != "resolved":
|
||||
raise AppException(3042, "只能对已结单的会话发送评价邀请")
|
||||
|
||||
# 3. 检查是否已评价
|
||||
eval_stmt = select(ConversationEvaluation).where(
|
||||
ConversationEvaluation.conversation_id == conversation_id
|
||||
)
|
||||
eval_result = await db.execute(eval_stmt)
|
||||
existing_eval = eval_result.scalars().first()
|
||||
|
||||
if existing_eval:
|
||||
raise AppException(3043, "该会话已收到评价,无需再次邀请")
|
||||
|
||||
# 4. 通过企微发送评价邀请消息
|
||||
try:
|
||||
wecom_service = WecomService(redis)
|
||||
|
||||
# 构建评价邀请消息内容
|
||||
agent_name = current_agent.name if current_agent else "IT服务台"
|
||||
content = (
|
||||
f"您好!您与 {agent_name} 的会话已结束。\n\n"
|
||||
f"请对本次服务进行评价,帮助我们改进服务质量。\n\n"
|
||||
f"点击下方链接进行评价 >>"
|
||||
)
|
||||
|
||||
# TODO: 后续接入企微应用消息推送
|
||||
# message_data = {
|
||||
# "touser": conversation.employee_id,
|
||||
# "msgtype": "text",
|
||||
# "agentid": settings.WECOM_AGENT_ID,
|
||||
# "text": {"content": content},
|
||||
# }
|
||||
# await wecom_service.send_message(message_data)
|
||||
|
||||
logger.info(
|
||||
f"发送评价邀请: 会话={conversation_id}, "
|
||||
f"员工={conversation.employee_id}, 坐席={agent_name}"
|
||||
)
|
||||
|
||||
# 关闭企微服务连接
|
||||
await wecom_service.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"发送评价邀请失败: {e}")
|
||||
# 失败不影响结单流程,只记录日志
|
||||
|
||||
return success_response(data={"message": "评价邀请已发送"})
|
||||
+507
-16
@@ -44,6 +44,7 @@ limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.utils.env_gating import is_production
|
||||
from app.dependencies import dep_redis, dep_wecom_service, dep_ai_handler
|
||||
from app.models.approval_link import ApprovalLink
|
||||
from app.models.conversation import Conversation
|
||||
@@ -83,18 +84,18 @@ _WEWORK_UA_RE = re.compile(r"wxwork", re.IGNORECASE)
|
||||
def _require_wework_ua(request: Request) -> None:
|
||||
"""校验请求 User-Agent 是否来自企微 WebView。
|
||||
|
||||
生产环境下,非企微环境的 OAuth2 请求直接拒绝。
|
||||
本地开发(localhost / 127.0.0.1)跳过检测,方便调试。
|
||||
仅生产环境强制校验(env_gating.is_production()):
|
||||
非企微环境的 OAuth2 请求直接拒绝。
|
||||
本地开发 / dev / test 环境跳过检测,方便调试。
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 对象,用于读取 User-Agent 和 Host
|
||||
request: FastAPI Request 对象,用于读取 User-Agent
|
||||
|
||||
Raises:
|
||||
AppException: 非企微环境时抛出 403 错误
|
||||
AppException: 非企微环境且处于生产环境时抛出 4003 错误
|
||||
"""
|
||||
# 本地开发跳过检测
|
||||
host = request.headers.get("host", "")
|
||||
if host.startswith("localhost") or host.startswith("127.0.0.1"):
|
||||
# 仅生产环境强制校验;非生产环境(dev/test/本地)一律放行
|
||||
if not is_production():
|
||||
return
|
||||
|
||||
ua = request.headers.get("user-agent", "")
|
||||
@@ -253,11 +254,11 @@ async def get_oauth_authorize_url(
|
||||
elif request_host:
|
||||
# 从 Host 头构造回调地址(支持 http 和 https)
|
||||
scheme = "https" # 企微H5应用通常使用 https
|
||||
encoded_redirect = quote(f"{scheme}://{request_host}/itportal/", safe="")
|
||||
encoded_redirect = quote(f"{scheme}://{request_host}/itdesk/", safe="")
|
||||
else:
|
||||
# 最终降级:使用配置中的 CORS 源地址
|
||||
default_origin = settings.cors_origins_list[0] if settings.cors_origins_list else "https://localhost"
|
||||
encoded_redirect = quote(f"{default_origin}/itportal/", safe="")
|
||||
encoded_redirect = quote(f"{default_origin}/itdesk/", safe="")
|
||||
|
||||
# 构造企微OAuth2静默授权URL(snsapi_base:用户无感知)
|
||||
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com)
|
||||
@@ -362,11 +363,14 @@ async def oauth_callback(
|
||||
employee.name = employee_name
|
||||
employee.department = department
|
||||
employee.position = position
|
||||
# 【FE-UA-005 优化】每次登录强制更新头像URL,确保获取最新头像
|
||||
# 【FE-UA-005 优化】每次登录强制更新头像URL + 清缓存(统一走 avatar_service)
|
||||
# 头像更新失败不阻塞登录(sync_employee_avatar 内部已容错)
|
||||
if avatar:
|
||||
employee.avatar = avatar
|
||||
employee.avatar_updated_at = datetime.utcnow()
|
||||
logger.info(f"更新员工头像: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, employee_id, avatar)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
|
||||
else:
|
||||
# 创建新记录
|
||||
employee = Employee(
|
||||
@@ -437,6 +441,137 @@ async def oauth_callback(
|
||||
raise AppException(2007, f"OAuth2授权失败: {e}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/oauth/sns-callback — 企微 OAuth2 静默授权回调(302 重定向版)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/h5/oauth/sns-callback")
|
||||
async def oauth_sns_callback(
|
||||
request: Request,
|
||||
code: str = Query(..., description="企微 OAuth2 授权码"),
|
||||
state: Optional[str] = Query(None, description="透传参数(保留兼容,未使用)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis_client: Optional[aioredis.Redis] = Depends(dep_redis),
|
||||
wecom_service: WecomService = Depends(dep_wecom_service),
|
||||
):
|
||||
"""企微 OAuth2 静默授权回调(snsapi_base → 302 带 ?token=)。
|
||||
|
||||
适用于 snsapi_base 静默授权:企微回调到此端点并携带 code,
|
||||
后端用 code 换取员工身份 → 生成 Bearer Token → 302 重定向到
|
||||
H5 前端页面,并在 URL 上附带 ?token=,供前端镜像到 localStorage。
|
||||
|
||||
仅生产环境强制 UA 校验(与 _require_wework_ua 一致,使用 env_gating)。
|
||||
|
||||
Args:
|
||||
code: 企微授权码
|
||||
state: 透传参数(未使用,保留兼容)
|
||||
db: 数据库会话
|
||||
redis_client: 共享 Redis 客户端(DI 注入)
|
||||
wecom_service: 共享企微服务(DI 注入)
|
||||
|
||||
Returns:
|
||||
RedirectResponse -> {scheme}://{host}/itdesk/?token={token}
|
||||
"""
|
||||
# 仅生产环境强制 UA 校验
|
||||
if is_production():
|
||||
ua = request.headers.get("user-agent", "")
|
||||
if not _WEWORK_UA_RE.search(ua):
|
||||
raise AppException(4003, "请在企业微信中访问此服务")
|
||||
|
||||
# 1. 用 code 换取员工身份
|
||||
user_info = await wecom_service.get_oauth_user_info(code)
|
||||
employee_id = user_info.get("userid", "")
|
||||
if not employee_id:
|
||||
raise AppException(2007, "OAuth2授权失败:未获取到员工ID")
|
||||
|
||||
# 2. 获取员工详细信息(姓名、部门、岗位、头像)
|
||||
employee_name = ""
|
||||
department = ""
|
||||
position = ""
|
||||
avatar = ""
|
||||
try:
|
||||
detail = await wecom_service.get_user_info(employee_id)
|
||||
employee_name = detail.get("name", "")
|
||||
dept_ids = detail.get("department", [])
|
||||
department = ",".join(str(d) for d in dept_ids) if dept_ids else ""
|
||||
position = detail.get("position", "")
|
||||
avatar = detail.get("avatar", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
# 3. 落库 / 更新员工信息(含头像)
|
||||
try:
|
||||
from app.models.employee import Employee
|
||||
|
||||
stmt = select(Employee).where(
|
||||
Employee.employee_id == employee_id,
|
||||
Employee.corp_id == settings.wecom_corp_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee:
|
||||
employee.name = employee_name
|
||||
employee.department = department
|
||||
employee.position = position
|
||||
if avatar:
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, employee_id, avatar)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
|
||||
else:
|
||||
employee = Employee(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
employee_id=employee_id,
|
||||
name=employee_name,
|
||||
department=department,
|
||||
position=position,
|
||||
avatar=avatar,
|
||||
)
|
||||
db.add(employee)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
# 4. 生成 Bearer Token 并写入 Redis
|
||||
token = secrets.token_urlsafe(32)
|
||||
if redis_client:
|
||||
try:
|
||||
await redis_client.setex(
|
||||
f"employee:token:{token}",
|
||||
EMPLOYEE_TOKEN_TTL_SECONDS,
|
||||
employee_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入失败(token 不会持久化): {e}")
|
||||
|
||||
employee_info_cache = {
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name,
|
||||
"department": department,
|
||||
"position": position,
|
||||
"avatar": avatar,
|
||||
}
|
||||
try:
|
||||
await redis_client.setex(
|
||||
f"employee:info:{employee_id}",
|
||||
EMPLOYEE_TOKEN_TTL_SECONDS,
|
||||
json.dumps(employee_info_cache, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"员工信息缓存写入失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"OAuth2 sns-callback 授权成功: employee_id={employee_id}, name={employee_name}")
|
||||
|
||||
# 5. 302 重定向到 H5 前端页面,附带 ?token= 供前端镜像到 localStorage
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
host = request.headers.get("host", "")
|
||||
scheme = "https"
|
||||
landing = "/itdesk/"
|
||||
redirect_url = f"{scheme}://{host}{landing}?token={token}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/mock-login — Mock 登录(测试阶段,跳过 OAuth2)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -856,8 +991,75 @@ async def h5_send_message(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
|
||||
# GET /api/h5/conversations/current/messages — 用户获取消息列表(历史消息)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/h5/conversations/current/messages")
|
||||
async def h5_get_messages(
|
||||
limit: int = Query(50, description="每页消息数量,默认50"),
|
||||
before: Optional[str] = Query(None, description="获取此消息ID之前的消息(向上翻页)"),
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""H5 用户获取消息列表(历史消息)。
|
||||
|
||||
前端在进入会话或切换会话时调用,获取完整的消息历史记录。
|
||||
支持分页向上翻页(通过 before 参数)。
|
||||
|
||||
Args:
|
||||
limit: 每页消息数量(默认50)
|
||||
before: 消息ID,获取此消息之前的消息(向上翻页)
|
||||
employee_id: 员工企微 UserID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含消息列表和 has_more 标志
|
||||
"""
|
||||
# 查找当前会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status.in_(["ai_handling", "queued", "serving"]),
|
||||
).order_by(Conversation.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
return success_response(data={"items": [], "has_more": False})
|
||||
|
||||
# 查询消息列表
|
||||
msg_stmt = select(Message).where(
|
||||
Message.conversation_id == conversation.id
|
||||
).order_by(Message.created_at.desc()).limit(limit)
|
||||
|
||||
# 如果指定了 before,获取此消息之前的消息
|
||||
if before:
|
||||
try:
|
||||
from uuid import UUID as UUIDType
|
||||
UUIDType(before) # 仅校验格式
|
||||
|
||||
# 查询 before 消息的创建时间
|
||||
before_stmt = select(Message.created_at).where(
|
||||
Message.id == str(before)
|
||||
)
|
||||
before_result = await db.execute(before_stmt)
|
||||
before_time = before_result.scalar_one_or_none()
|
||||
|
||||
if before_time:
|
||||
msg_stmt = msg_stmt.where(Message.created_at < before_time)
|
||||
except ValueError:
|
||||
pass # 无效的UUID格式,忽略 before 参数
|
||||
|
||||
msg_result = await db.execute(msg_stmt)
|
||||
messages = list(msg_result.scalars().all())
|
||||
|
||||
# 反转顺序(按时间正序返回)
|
||||
messages.reverse()
|
||||
|
||||
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
||||
# 判断是否还有更多:查询的消息数是否等于 limit
|
||||
has_more = len(messages) == limit
|
||||
|
||||
return success_response(data={"items": items, "has_more": has_more})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
|
||||
@@ -1019,18 +1221,307 @@ async def shake(
|
||||
except Exception as e:
|
||||
logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}")
|
||||
# 5. 自动分配空闲坐席
|
||||
from app.services.session_service import SessionService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.models.agent import Agent
|
||||
|
||||
# 5. 返回会话信息和话术
|
||||
assigned_agent_id: Optional[str] = None
|
||||
assign_result: str = "queued"
|
||||
|
||||
# 查找在线且未满负荷的坐席(按当前负载升序,取第一个)
|
||||
stmt = select(Agent).where(
|
||||
Agent.status == "online",
|
||||
Agent.current_load < Agent.max_load
|
||||
).order_by(Agent.current_load).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
available_agent = result.scalars().first()
|
||||
|
||||
if available_agent:
|
||||
# 找到空闲坐席,分配给该会话
|
||||
try:
|
||||
session_service = SessionService(db, wecom_service)
|
||||
await session_service.assign_agent(conversation.id, available_agent.user_id)
|
||||
assigned_agent_id = available_agent.user_id
|
||||
assign_result = "assigned"
|
||||
logger.info(f"自动分配坐席: conv_id={conversation.id}, agent={assigned_agent_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"自动分配坐席失败: {e}")
|
||||
assign_result = "assign_failed"
|
||||
else:
|
||||
# 无空闲坐席,进入排队(会话状态保持 queued,由 AI 未命中时自动处理)
|
||||
assign_result = "queued"
|
||||
logger.info(f"无空闲坐席,会话进入排队: conv_id={conversation.id}")
|
||||
|
||||
# 6. 广播 new_conversation 事件通知所有坐席
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_conversation",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name or "未知用户",
|
||||
"urgency_score": conversation.urgency_score,
|
||||
"hand_raise": True,
|
||||
"assigned_agent_id": assigned_agent_id,
|
||||
"assign_result": assign_result,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}")
|
||||
|
||||
# 7. 返回会话信息和话术
|
||||
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(
|
||||
data={
|
||||
"conversation": conv_data,
|
||||
"funny_phrase": phrase,
|
||||
"assign_result": assign_result,
|
||||
"assigned_agent_id": assigned_agent_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/call-agent — 摇人按钮触发转人工
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/call-agent")
|
||||
async def call_agent(
|
||||
body: ShakeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wecom_service: Optional[WecomService] = Depends(dep_wecom_service),
|
||||
):
|
||||
"""摇人按钮 - 呼叫坐席。
|
||||
|
||||
用户点击摇人按钮后,触发转人工流程:
|
||||
1. 查找当前会话
|
||||
2. 校验AI回复次数 >= 3(与shake一致)
|
||||
3. 将会话状态改为 queued(排队中)
|
||||
4. 尝试分配空闲坐席
|
||||
5. 发送系统消息通知用户
|
||||
6. 通过企微消息通知坐席
|
||||
|
||||
Args:
|
||||
body: 呼叫坐席请求体(包含 employee_id 和 employee_name)
|
||||
db: 数据库会话
|
||||
wecom_service: 共享企微服务(DI 注入)
|
||||
|
||||
Returns:
|
||||
Dict: 包含会话信息和排队状态
|
||||
"""
|
||||
from app.services.session_service import SessionService
|
||||
|
||||
employee_id = body.employee_id
|
||||
employee_name = body.employee_name
|
||||
|
||||
# 1. 查找当前活跃会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status.in_(["ai_handling", "queued", "serving"]),
|
||||
).order_by(Conversation.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 2. 前置校验:必须满足 AI 实质性回复 >= 3 次
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
if employee_name and not conversation.employee_name:
|
||||
conversation.employee_name = employee_name
|
||||
|
||||
# 3. 将会话状态改为 queued(排队中)
|
||||
conversation.status = "queued"
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.updated_at = datetime.now()
|
||||
|
||||
# 设置紧急度加分
|
||||
tags = dict(conversation.tags) if conversation.tags else {}
|
||||
tags["user_called_agent"] = True # 标记用户主动呼叫
|
||||
conversation.tags = tags
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
|
||||
# 4. 尝试分配空闲坐席
|
||||
session_service = SessionService(db)
|
||||
assigned_agent = await session_service.auto_assign_agent(conversation.id)
|
||||
|
||||
# 5. 获取趣味话术
|
||||
funny_phrase_service = FunnyPhraseService(db)
|
||||
is_vip = conversation.is_vip
|
||||
phrase = await funny_phrase_service.get_phrase("transfer", is_vip=is_vip)
|
||||
|
||||
# 6. 创建系统消息
|
||||
system_content = phrase
|
||||
if assigned_agent:
|
||||
system_content = f"{phrase}\n\n为您服务的是:{assigned_agent.name}"
|
||||
conversation.status = "serving"
|
||||
conversation.assigned_agent_id = assigned_agent.user_id
|
||||
|
||||
system_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="system",
|
||||
sender_id="system",
|
||||
sender_name="系统",
|
||||
content=system_content,
|
||||
msg_type="system",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(system_msg)
|
||||
|
||||
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
||||
if wecom_service:
|
||||
try:
|
||||
await wecom_service.send_text_message(employee_id, system_content)
|
||||
except Exception as e:
|
||||
logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
|
||||
|
||||
# 8. 如果分配了坐席,通知坐席有新会话
|
||||
if assigned_agent and wecom_service:
|
||||
try:
|
||||
notify_phrase = f"新会话:{employee_name} 呼叫人工服务,请及时接单"
|
||||
# 获取坐席的userid并发送通知(需要坐席绑定企微)
|
||||
# 此处简化处理,仅记录日志
|
||||
logger.info(f"分配坐席: agent_id={assigned_agent.id}, employee_id={employee_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"坐席通知失败: {e}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"呼叫坐席: employee_id={employee_id}, conv_id={conversation.id}, agent_id={assigned_agent.id if assigned_agent else 'None'}")
|
||||
|
||||
# 9. 返回结果
|
||||
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(
|
||||
data={
|
||||
"conversation": conv_data,
|
||||
"status": conversation.status,
|
||||
"queue_position": 1 if not assigned_agent else None,
|
||||
"estimated_wait_seconds": 30 if not assigned_agent else 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/queue-status — 查询排队状态
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/h5/conversations/current/queue-status")
|
||||
async def get_queue_status(
|
||||
employee_id: str = Query(..., description="员工ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询当前排队状态。
|
||||
|
||||
返回当前会话的排队位置和预计等待时间。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
|
||||
Returns:
|
||||
Dict: 排队状态信息
|
||||
"""
|
||||
from sqlalchemy import select, func
|
||||
from app.models.conversation import Conversation
|
||||
|
||||
# 1. 查找该员工的排队会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status == "queued",
|
||||
).order_by(Conversation.created_at.asc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
# 不在排队中,可能是已分配或无会话
|
||||
return success_response(data={
|
||||
"in_queue": False,
|
||||
"status": None,
|
||||
"queue_position": None,
|
||||
"estimated_wait_seconds": 0,
|
||||
})
|
||||
|
||||
# 2. 计算排队位置(按创建时间排序)
|
||||
count_stmt = select(func.count(Conversation.id)).where(
|
||||
Conversation.status == "queued",
|
||||
Conversation.created_at < conversation.created_at,
|
||||
)
|
||||
count_result = await db.execute(count_stmt)
|
||||
queue_position = count_result.scalar() or 0
|
||||
|
||||
# 3. 计算预计等待时间(基于平均处理时长5分钟)
|
||||
estimated_wait_seconds = queue_position * 300 # 5分钟/人
|
||||
|
||||
return success_response(data={
|
||||
"in_queue": True,
|
||||
"status": conversation.status,
|
||||
"queue_position": queue_position + 1,
|
||||
"estimated_wait_seconds": estimated_wait_seconds,
|
||||
"conversation_id": str(conversation.id),
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/cancel-queue — 取消排队
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/cancel-queue")
|
||||
async def cancel_queue(
|
||||
body: ShakeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""取消排队。
|
||||
|
||||
用户主动取消排队,释放排队位置。
|
||||
|
||||
Args:
|
||||
body: 包含 employee_id
|
||||
|
||||
Returns:
|
||||
Dict: 操作结果
|
||||
"""
|
||||
employee_id = body.employee_id
|
||||
|
||||
# 1. 查找排队中的会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status == "queued",
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(code=1004, message="您当前不在排队中")
|
||||
|
||||
# 2. 将会话状态改回 ai_handling
|
||||
conversation.status = "ai_handling"
|
||||
conversation.updated_at = datetime.now()
|
||||
|
||||
# 移除用户主动呼叫标记
|
||||
tags = dict(conversation.tags) if conversation.tags else {}
|
||||
tags.pop("user_called_agent", None)
|
||||
conversation.tags = tags
|
||||
|
||||
db.add(conversation)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"取消排队: employee_id={employee_id}, conv_id={conversation.id}")
|
||||
|
||||
return success_response(data={
|
||||
"message": "已取消排队,会话将继续由AI服务",
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/approval-links — 获取审批流程链接
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库 API
|
||||
# =============================================================================
|
||||
# 说明:知识库FAQ管理接口,包括:
|
||||
# 1. GET /api/knowledge - 获取知识库列表
|
||||
# 2. POST /api/knowledge - 创建知识条目
|
||||
# 3. PUT /api/knowledge/{id} - 更新知识条目
|
||||
# 4. DELETE /api/knowledge/{id} - 删除知识条目
|
||||
# 5. GET /api/knowledge/search - 搜索知识
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
from app.schemas.knowledge_base import (
|
||||
KnowledgeBaseCreate,
|
||||
KnowledgeBaseResponse,
|
||||
KnowledgeBaseUpdate,
|
||||
)
|
||||
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/knowledge — 获取知识库列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/knowledge")
|
||||
async def list_knowledge(
|
||||
category: Optional[str] = Query(None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取知识库列表。
|
||||
|
||||
支持按分类筛选和关键词搜索。
|
||||
|
||||
Args:
|
||||
category: 按分类筛选(可选)
|
||||
keyword: 关键词搜索(可选,搜索标题和内容)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含知识库列表
|
||||
"""
|
||||
stmt = select(KnowledgeBase).order_by(KnowledgeBase.view_count.desc())
|
||||
|
||||
if category:
|
||||
stmt = stmt.where(KnowledgeBase.category == category)
|
||||
|
||||
if keyword:
|
||||
# 关键词搜索:标题或内容包含关键字
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
KnowledgeBase.title.ilike(f"%{keyword}%"),
|
||||
KnowledgeBase.content.ilike(f"%{keyword}%"),
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
data = [KnowledgeBaseResponse.model_validate(t).model_dump() for t in items]
|
||||
return success_response(data={"items": data})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/knowledge — 创建知识条目
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/knowledge")
|
||||
async def create_knowledge(
|
||||
body: KnowledgeBaseCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建知识库条目。
|
||||
|
||||
Args:
|
||||
body: 创建请求体
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含创建的知识条目
|
||||
"""
|
||||
knowledge = KnowledgeBase(
|
||||
category=body.category,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
tags=body.tags,
|
||||
)
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"创建知识库条目: category={body.category}, title={body.title}")
|
||||
|
||||
data = KnowledgeBaseResponse.model_validate(knowledge).model_dump()
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/knowledge/{id} — 更新知识条目
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/knowledge/{knowledge_id}")
|
||||
async def update_knowledge(
|
||||
knowledge_id: UUID,
|
||||
body: KnowledgeBaseUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新知识库条目。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
body: 更新请求体
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的知识条目
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 只更新传入的字段
|
||||
if body.category is not None:
|
||||
knowledge.category = body.category
|
||||
if body.title is not None:
|
||||
knowledge.title = body.title
|
||||
if body.content is not None:
|
||||
knowledge.content = body.content
|
||||
if body.tags is not None:
|
||||
knowledge.tags = body.tags
|
||||
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"更新知识库条目: id={knowledge_id}")
|
||||
|
||||
data = KnowledgeBaseResponse.model_validate(knowledge).model_dump()
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DELETE /api/knowledge/{id} — 删除知识条目
|
||||
# --------------------------------------------------------------------------
|
||||
@router.delete("/knowledge/{knowledge_id}")
|
||||
async def delete_knowledge(
|
||||
knowledge_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除知识库条目。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
await db.delete(knowledge)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"删除知识库条目: id={knowledge_id}")
|
||||
|
||||
return success_response(data=None, message="删除成功")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/knowledge/{id}/view — 更新查看次数
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/knowledge/{knowledge_id}/view")
|
||||
async def view_knowledge(
|
||||
knowledge_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""记录知识库条目被查看。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
knowledge.view_count += 1
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
return success_response(data={"view_count": knowledge.view_count})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/knowledge/{id}/use — 更新使用次数
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/knowledge/{knowledge_id}/use")
|
||||
async def use_knowledge(
|
||||
knowledge_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""记录知识库条目被使用(坐席引用)。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
knowledge.use_count += 1
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
return success_response(data={"use_count": knowledge.use_count})
|
||||
@@ -0,0 +1,266 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库自动迭代 API
|
||||
# =============================================================================
|
||||
# 说明:知识库自动迭代相关接口
|
||||
# 1. POST /api/admin/knowledge-iteration/analyze - 触发分析并生成建议
|
||||
# 2. GET /api/admin/knowledge-iteration/suggestions - 获取建议列表
|
||||
# 3. GET /api/admin/knowledge-iteration/suggestions/{id} - 获取建议详情
|
||||
# 4. POST /api/admin/knowledge-iteration/suggestions/{id}/approve - 审核通过
|
||||
# 5. POST /api/admin/knowledge-iteration/suggestions/{id}/reject - 审核拒绝
|
||||
# 6. GET /api/admin/knowledge-iteration/stats - 获取统计
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models.user import User
|
||||
from app.schemas.knowledge_suggestion import (
|
||||
KnowledgeSuggestionListResponse,
|
||||
KnowledgeSuggestionResponse,
|
||||
KnowledgeSuggestionStatsResponse,
|
||||
KnowledgeSuggestionApprove,
|
||||
KnowledgeSuggestionReject,
|
||||
)
|
||||
from app.services.knowledge_iteration_service import (
|
||||
KnowledgeIterationService,
|
||||
dep_knowledge_iteration_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 触发分析
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/analyze
|
||||
@router.post("/analyze")
|
||||
async def trigger_analysis(
|
||||
days: int = Query(default=7, ge=1, le=90, description="分析过去N天的数据"),
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""触发知识库迭代分析。
|
||||
|
||||
分析过去N天的标注数据和会话数据,自动生成优化建议。
|
||||
|
||||
- **days**: 分析过去N天的数据(默认7天,最大90天)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(f"管理员 {current_user.username} 触发了知识库迭代分析, days={days}")
|
||||
|
||||
result = await service.analyze_and_generate_suggestions(db, days=days)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "分析完成",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取建议列表
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions
|
||||
@router.get("/suggestions")
|
||||
async def list_suggestions(
|
||||
status: Optional[str] = Query(default=None, description="筛选状态"),
|
||||
suggestion_type: Optional[str] = Query(default=None, description="筛选类型"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议列表。
|
||||
|
||||
- **status**: 筛选状态(pending/approved/rejected/applied)
|
||||
- **suggestion_type**: 筛选类型(new_faq/update/outdated)
|
||||
- **page**: 页码
|
||||
- **page_size**: 每页数量
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
from sqlalchemy import select, func
|
||||
|
||||
# 构建查询
|
||||
stmt = select(KnowledgeSuggestion).order_by(
|
||||
KnowledgeSuggestion.created_at.desc()
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(KnowledgeSuggestion.status == status)
|
||||
if suggestion_type:
|
||||
stmt = stmt.where(KnowledgeSuggestion.suggestion_type == suggestion_type)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
suggestions = result.scalars().all()
|
||||
|
||||
# 统计总数
|
||||
count_stmt = select(func.count()).select_from(KnowledgeSuggestion)
|
||||
if status:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.status == status)
|
||||
if suggestion_type:
|
||||
count_stmt = count_stmt.where(
|
||||
KnowledgeSuggestion.suggestion_type == suggestion_type
|
||||
)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total": total,
|
||||
"items": [
|
||||
KnowledgeSuggestionResponse.model_validate(s) for s in suggestions
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取建议详情
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id}
|
||||
@router.get("/suggestions/{suggestion_id}")
|
||||
async def get_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取知识库优化建议详情。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(KnowledgeSuggestion).where(
|
||||
KnowledgeSuggestion.id == suggestion_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
suggestion = result.scalar_one_or_none()
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 审核通过
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve
|
||||
@router.post("/suggestions/{suggestion_id}/approve")
|
||||
async def approve_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionApprove,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""审核通过知识库优化建议。
|
||||
|
||||
审核通过后,如果是新FAQ或更新建议,将自动添加到知识库。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.username} 审核通过建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
suggestion = await service.approve_suggestion(
|
||||
db, suggestion_id, current_user.id
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "审核通过,建议已应用到知识库",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 审核拒绝
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject
|
||||
@router.post("/suggestions/{suggestion_id}/reject")
|
||||
async def reject_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionReject,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""拒绝知识库优化建议。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.username} 拒绝建议: {suggestion_id}, "
|
||||
f"理由: {body.reject_reason}"
|
||||
)
|
||||
|
||||
suggestion = await service.reject_suggestion(
|
||||
db, suggestion_id, current_user.id, body.reject_reason
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "已拒绝该建议",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取统计
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/stats
|
||||
@router.get("/stats")
|
||||
async def get_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议统计。
|
||||
|
||||
返回各状态的建议数量统计。
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
stats = await service.get_suggestion_stats(db)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": KnowledgeSuggestionStatsResponse(**stats),
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 统一 OTP 二次认证 API(三端认证重构 AUTH-03)
|
||||
# =============================================================================
|
||||
# 说明:三端(H5 员工端 / 坐席端 / 管理端)统一的 OTP(TOTP) 二次认证路由。
|
||||
#
|
||||
# 端点列表(前缀 /auth,nginx 会剥离 /api 前缀,对外即 /api/auth/otp-*):
|
||||
# 1. GET /auth/otp-status — 查询绑定状态(路由守卫用)
|
||||
# 2. POST /auth/otp-bind — 生成 secret + 二维码(尚未启用)
|
||||
# 3. POST /auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
|
||||
# 4. POST /auth/otp-unbind — 用户主动关闭 MFA
|
||||
# 5. POST /auth/otp-admin-reset/{id} — 管理员重置(员工丢手机兜底)
|
||||
# 6. GET /auth/otp-admin-users — 管理员查看全部坐席 MFA 绑定状态
|
||||
#
|
||||
# 设计要点(与 system_design.md 对齐):
|
||||
# - 复用 MFAService(pyotp + qrcode)与 agents 表的 mfa_* 字段
|
||||
# - 验证通过后在 Redis 写 mfa:verified:{employee_id},TTL=1800s
|
||||
# - 与 dependencies.require_high_risk_otp 共用同一 Redis key(契约不变)
|
||||
# - 取代原 /mfa/* 与 /admin/mfa/* 以及 agents 内联 /agents/otp-* 端点
|
||||
#
|
||||
# 鉴权:
|
||||
# - 1-4 用 get_current_user(任意已登录用户)
|
||||
# - 5-6 用 require_role("admin")(管理员)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import UserInfo, get_current_user, require_role
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.mfa import (
|
||||
MFABindConfirmRequest,
|
||||
MFABindStartResponse,
|
||||
MFADisableRequest,
|
||||
MFADisableResponse,
|
||||
MFAStatusResponse,
|
||||
MFAVerifyRequest,
|
||||
MFAVerifyResponse,
|
||||
)
|
||||
from app.services.mfa_service import MFA_VERIFIED_TTL_SECONDS, MFAService
|
||||
from app.utils.error_codes import ErrorCode
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 路由配置:统一前缀 /auth
|
||||
# -----------------------------------------------------------------------------
|
||||
router = APIRouter(prefix="/auth", tags=["OTP二次认证"])
|
||||
|
||||
|
||||
def _get_redis() -> aioredis.Redis:
|
||||
"""获取 Redis 客户端(模块级 helper,便于测试 patch)。
|
||||
|
||||
Returns:
|
||||
aioredis.Redis: Redis 异步客户端
|
||||
"""
|
||||
return settings.create_redis_client()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 通用工具:根据 user_id(employee_id) 查 Agent 记录
|
||||
# -----------------------------------------------------------------------------
|
||||
async def _get_agent_by_employee_id(
|
||||
db: AsyncSession, employee_id: str
|
||||
) -> Optional[Agent]:
|
||||
"""按 user_id(employee_id)查询 Agent 行。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 用户标识(企微 userid)
|
||||
|
||||
Returns:
|
||||
Optional[Agent]: 找不到返回 None
|
||||
"""
|
||||
stmt = select(Agent).where(Agent.user_id == employee_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 通用工具:验证当前用户是否已登录 + 取得 Agent 行
|
||||
# -----------------------------------------------------------------------------
|
||||
async def _require_agent(
|
||||
db: AsyncSession, current_user: UserInfo
|
||||
) -> Agent:
|
||||
"""根据当前 token 取出对应的 Agent 行,不存在则 404。
|
||||
|
||||
MFA 状态 / secret 都存放在 agents 表,不是 employees 表。
|
||||
|
||||
Raises:
|
||||
AppException: 坐席不存在(E4001)
|
||||
"""
|
||||
agent = await _get_agent_by_employee_id(db, current_user.employee_id)
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.AGENT_NOT_FOUND, "坐席不存在,无法进行 OTP 操作")
|
||||
return agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. GET /auth/otp-status — 查询绑定状态
|
||||
# =============================================================================
|
||||
@router.get("/otp-status", response_model=None)
|
||||
async def get_otp_status(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""查询当前用户的 OTP 绑定状态。
|
||||
|
||||
前端路由守卫使用:
|
||||
- bound=false → 强制走绑定流程
|
||||
- bound=true → 跳到"输入 OTP 验证"或继续业务
|
||||
|
||||
Returns:
|
||||
success_response({bound, enabled, last_verified_at, verified})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
# 是否处于 30 分钟验证窗口内(与 require_high_risk_otp 共用 key)
|
||||
verified = await MFAService.is_verified(redis, agent.user_id)
|
||||
|
||||
return success_response(data=MFAStatusResponse(
|
||||
bound=bool(agent.mfa_enabled and agent.mfa_secret),
|
||||
enabled=bool(agent.mfa_enabled),
|
||||
last_verified_at=agent.mfa_last_verified_at,
|
||||
).model_dump(mode="json") | {"verified": verified})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. POST /auth/otp-bind — 生成 secret + 二维码
|
||||
# =============================================================================
|
||||
@router.post("/otp-bind", response_model=None)
|
||||
async def bind_otp(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""生成 TOTP 密钥和二维码。
|
||||
|
||||
行为:
|
||||
- 生成 32 位 base32 secret
|
||||
- 把 secret 写入 agents.mfa_secret(mfa_enabled=False, mfa_bound_at=None)
|
||||
- 返回 otpauth URI + base64 二维码 PNG(给前端展示)
|
||||
|
||||
重复调用策略:
|
||||
- 已 enabled=True → 拒绝,要求先 unbind 再重新绑定
|
||||
- 仅 secret 存在但 enabled=False → 复用旧 secret(支持"刷新二维码")
|
||||
|
||||
Returns:
|
||||
success_response({secret, otpauth_url, qr_code_base64})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
# 已启用则拒绝重新绑定(必须先 unbind)
|
||||
if agent.mfa_enabled:
|
||||
raise AppException(
|
||||
ErrorCode.INVALID_PARAMETER,
|
||||
"已绑定 OTP,如需重新绑定请先关闭",
|
||||
)
|
||||
|
||||
# 复用旧 secret 还是新生成?
|
||||
if agent.mfa_secret:
|
||||
secret = agent.mfa_secret
|
||||
else:
|
||||
secret = MFAService.generate_secret()
|
||||
agent.mfa_secret = secret
|
||||
# mfa_enabled 保持 False,mfa_bound_at 等首次验证通过再写
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
otpauth_url = MFAService.build_provisioning_uri(secret, agent.user_id)
|
||||
qr_base64 = MFAService.render_qrcode_base64(otpauth_url)
|
||||
|
||||
logger.info(f"OTP bind: agent={agent.user_id}, secret_prefix={secret[:4]}...")
|
||||
|
||||
return success_response(data=MFABindStartResponse(
|
||||
secret=secret,
|
||||
otpauth_url=otpauth_url,
|
||||
qr_code_base64=qr_base64,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. POST /auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
|
||||
# =============================================================================
|
||||
@router.post("/otp-verify", response_model=None)
|
||||
async def verify_otp(
|
||||
body: MFAVerifyRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""校验 6 位码,在 Redis 写 30 分钟复用标记。
|
||||
|
||||
行为:
|
||||
- 校验通过 → mfa:verified:{employee_id}=1 TTL 1800s
|
||||
+ 更新 mfa_last_verified_at
|
||||
- 校验失败 → verified=false(不抛异常,前端可重试)
|
||||
|
||||
Returns:
|
||||
success_response({verified, expires_in})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
if not agent.mfa_enabled or not agent.mfa_secret:
|
||||
# 用户还没绑定 OTP,直接返回 verified=false(前端可据此跳转绑定流程)
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=False,
|
||||
expires_in=0,
|
||||
).model_dump())
|
||||
|
||||
# 校验
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
|
||||
logger.warning(f"OTP verify 验证码错误: agent={agent.user_id}")
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=False,
|
||||
expires_in=0,
|
||||
).model_dump())
|
||||
|
||||
# 写 Redis 复用标记(与 require_high_risk_otp 共用 key)
|
||||
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
|
||||
|
||||
# 更新最后验证时间
|
||||
now = datetime.now()
|
||||
agent.mfa_last_verified_at = now
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"OTP verify 通过: agent={agent.user_id}")
|
||||
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=True,
|
||||
expires_in=MFA_VERIFIED_TTL_SECONDS,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. POST /auth/otp-unbind — 用户主动关闭 OTP
|
||||
# =============================================================================
|
||||
@router.post("/otp-unbind", response_model=None)
|
||||
async def unbind_otp(
|
||||
body: MFADisableRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""关闭 OTP(清空 secret + disabled 标记)。
|
||||
|
||||
安全要求:必须先校验当前 OTP,防止误操作或被劫持后恶意关闭。
|
||||
|
||||
Returns:
|
||||
success_response({success: true})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
if not agent.mfa_enabled or not agent.mfa_secret:
|
||||
# 没绑定过,直接幂等成功
|
||||
return success_response(data=MFADisableResponse(success=True).model_dump())
|
||||
|
||||
# 必须先验证 OTP
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
|
||||
raise AppException(ErrorCode.INVALID_PARAMETER, "OTP 验证码错误,无法关闭 OTP")
|
||||
|
||||
# 清空字段
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
# mfa_last_verified_at 保留,作为历史记录
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
# 顺手清掉 Redis 验证标记(避免遗留)
|
||||
await MFAService.clear_verified(redis, agent.user_id)
|
||||
|
||||
logger.info(f"OTP unbind: agent={agent.user_id}")
|
||||
|
||||
return success_response(data=MFADisableResponse(success=True).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. POST /auth/otp-admin-reset/{employee_id} — 管理员重置(丢手机兜底)
|
||||
# =============================================================================
|
||||
# 注意:此端点不要求 otp_code(员工已无法提供),只校验 admin 角色
|
||||
# 鉴权:@require_role("admin") 装饰器强制
|
||||
# =============================================================================
|
||||
@router.post("/otp-admin-reset/{employee_id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def admin_reset_otp(
|
||||
employee_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""管理员重置指定员工的 OTP 绑定(无 OTP 验证)。
|
||||
|
||||
使用场景:
|
||||
- 员工丢手机 / 换手机 → 管理员后台"重置 OTP"按钮
|
||||
|
||||
Returns:
|
||||
success_response({success: true})
|
||||
"""
|
||||
stmt = select(Agent).where(Agent.user_id == employee_id)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.AGENT_NOT_FOUND, f"坐席 {employee_id} 不存在")
|
||||
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
# mfa_last_verified_at 保留,作为审计
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
# 顺手清 Redis 标记
|
||||
await MFAService.clear_verified(redis, employee_id)
|
||||
|
||||
logger.info(f"OTP admin reset: employee_id={employee_id} by={current_user.employee_id}")
|
||||
|
||||
return success_response(data={"success": True})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. GET /auth/otp-admin-users — 管理员查看全部坐席 OTP 绑定状态
|
||||
# =============================================================================
|
||||
@router.get("/otp-admin-users", response_model=None)
|
||||
@require_role("admin")
|
||||
async def admin_list_otp_users(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员查看全部坐席的 OTP 绑定状态。
|
||||
|
||||
Returns:
|
||||
success_response([{employee_id, name, mfa_enabled, mfa_bound_at,
|
||||
mfa_last_verified_at}, ...])
|
||||
"""
|
||||
stmt = select(Agent).order_by(Agent.user_id)
|
||||
result = await db.execute(stmt)
|
||||
agents = result.scalars().all()
|
||||
|
||||
users = [
|
||||
{
|
||||
"employee_id": a.user_id,
|
||||
"name": getattr(a, "name", "") or "",
|
||||
"mfa_enabled": bool(a.mfa_enabled),
|
||||
"mfa_bound_at": a.mfa_bound_at.isoformat() if a.mfa_bound_at else None,
|
||||
"mfa_last_verified_at": (
|
||||
a.mfa_last_verified_at.isoformat() if a.mfa_last_verified_at else None
|
||||
),
|
||||
}
|
||||
for a in agents
|
||||
]
|
||||
|
||||
return success_response(data=users)
|
||||
@@ -1,262 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Portal 统一入口 API
|
||||
# =============================================================================
|
||||
# 说明:统一入口(Portal)相关接口
|
||||
# 包含:
|
||||
# 1. 获取当前用户角色信息
|
||||
# 2. 切换当前角色
|
||||
# 3. 获取角色对应的入口 URL
|
||||
# 所有接口需要有效的 Bearer Token
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, UserInfo
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.role import Role
|
||||
from app.models.user_role import UserRole
|
||||
from app.schemas.role import (
|
||||
PortalUserInfo,
|
||||
RoleResponse,
|
||||
SwitchRoleRequest,
|
||||
SwitchRoleResponse,
|
||||
)
|
||||
from app.services.token_service import TokenService
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP Bearer 认证方案
|
||||
security = HTTPBearer()
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/portal")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取当前用户角色信息
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/roles")
|
||||
async def get_user_roles(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户的角色信息。
|
||||
|
||||
返回用户的基本信息和角色列表,用于路由选择页展示。
|
||||
|
||||
Args:
|
||||
current_user: 当前用户(通过认证依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含用户信息和角色列表
|
||||
"""
|
||||
# 查询用户拥有的角色
|
||||
stmt = (
|
||||
select(Role, UserRole)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == current_user.employee_id)
|
||||
.where(
|
||||
# 过滤已过期的角色
|
||||
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > func.now())
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
role_rows = result.all()
|
||||
|
||||
# 构建角色列表
|
||||
roles = []
|
||||
for role, user_role in role_rows:
|
||||
roles.append(
|
||||
RoleResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
display_name=role.display_name,
|
||||
description=role.description,
|
||||
permissions=role.permissions or [],
|
||||
is_default=role.is_default,
|
||||
created_at=role.created_at,
|
||||
updated_at=role.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# 如果用户没有任何角色,添加默认的 user 角色
|
||||
if not roles:
|
||||
# 查询 user 角色
|
||||
user_role_stmt = select(Role).where(Role.name == "user")
|
||||
user_role_result = await db.execute(user_role_stmt)
|
||||
user_role = user_role_result.scalars().first()
|
||||
|
||||
if user_role:
|
||||
roles.append(
|
||||
RoleResponse(
|
||||
id=user_role.id,
|
||||
name=user_role.name,
|
||||
display_name=user_role.display_name,
|
||||
description=user_role.description,
|
||||
permissions=user_role.permissions or [],
|
||||
is_default=user_role.is_default,
|
||||
created_at=user_role.created_at,
|
||||
updated_at=user_role.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# 构建响应
|
||||
user_info = PortalUserInfo(
|
||||
employee_id=current_user.employee_id,
|
||||
name=current_user.name,
|
||||
department=current_user.department,
|
||||
avatar=current_user.avatar,
|
||||
roles=roles,
|
||||
current_role=current_user.current_role,
|
||||
)
|
||||
|
||||
return success_response(data=user_info.model_dump())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 切换当前角色
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/switch-role")
|
||||
async def switch_role(
|
||||
body: SwitchRoleRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
"""切换当前角色。
|
||||
|
||||
更新 Redis Token 中的 current_role 字段,返回目标角色的入口 URL。
|
||||
|
||||
Args:
|
||||
body: 切换角色请求
|
||||
current_user: 当前用户(通过认证依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含切换后的角色和重定向 URL
|
||||
"""
|
||||
# 验证用户是否有目标角色
|
||||
stmt = (
|
||||
select(Role)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == current_user.employee_id)
|
||||
.where(Role.name == body.new_role)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
target_role = result.scalars().first()
|
||||
|
||||
if not target_role:
|
||||
raise AppException(4003, f"没有 {body.new_role} 角色权限")
|
||||
|
||||
# 更新 Redis Token 中的 current_role
|
||||
from app.dependencies import get_redis
|
||||
redis_client = await get_redis()
|
||||
token_service = TokenService(redis_client)
|
||||
|
||||
# 从请求头获取 token
|
||||
token = credentials.credentials
|
||||
switch_success = await token_service.switch_role(token, body.new_role)
|
||||
|
||||
if not switch_success:
|
||||
raise AppException(4003, "角色切换失败")
|
||||
|
||||
# 获取目标角色的入口 URL(传递 token 以便目标前端直接认证)
|
||||
token = credentials.credentials
|
||||
redirect_url = _get_role_url(body.new_role, token)
|
||||
|
||||
logger.info(f"用户 {current_user.employee_id} 切换角色到 {body.new_role}")
|
||||
|
||||
return success_response(
|
||||
data=SwitchRoleResponse(
|
||||
current_role=body.new_role,
|
||||
redirect_url=redirect_url,
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取角色对应的入口 URL
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/entry/{role_name}")
|
||||
async def get_role_entry(
|
||||
role_name: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
"""获取角色对应的入口 URL。
|
||||
|
||||
Args:
|
||||
role_name: 角色标识
|
||||
current_user: 当前用户(通过认证依赖注入)
|
||||
db: 数据库会话
|
||||
credentials: HTTP Bearer Token
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含角色信息和入口 URL
|
||||
"""
|
||||
# 验证用户是否有目标角色
|
||||
stmt = (
|
||||
select(Role)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == current_user.employee_id)
|
||||
.where(Role.name == role_name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
target_role = result.scalars().first()
|
||||
|
||||
if not target_role:
|
||||
raise AppException(4003, f"没有 {role_name} 角色权限")
|
||||
|
||||
# 获取入口 URL(传递 token 以便目标前端直接认证)
|
||||
token = credentials.credentials
|
||||
redirect_url = _get_role_url(role_name, token)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"role": role_name,
|
||||
"url": redirect_url,
|
||||
"display_name": target_role.display_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助函数:获取角色对应的 URL
|
||||
# --------------------------------------------------------------------------
|
||||
def _get_role_url(role_name: str, token: str = None) -> str:
|
||||
"""获取角色对应的前端 URL。
|
||||
|
||||
Args:
|
||||
role_name: 角色标识
|
||||
token: 可选的访问令牌,用于附加到重定向URL
|
||||
|
||||
Returns:
|
||||
str: 前端 URL(带token参数)
|
||||
"""
|
||||
role_urls = {
|
||||
"user": "/itdesk/",
|
||||
"agent": "/itagent/",
|
||||
"admin": "/itadmin/",
|
||||
}
|
||||
base_url = role_urls.get(role_name, "/itdesk/")
|
||||
|
||||
# 如果提供了token,附加到URL参数
|
||||
if token:
|
||||
# 添加 token 参数,使用 ? 或 & 连接
|
||||
separator = "&" if "?" in base_url else "?"
|
||||
return f"{base_url}{separator}token={token}"
|
||||
|
||||
return base_url
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.quick_reply_template import QuickReplyTemplate
|
||||
from app.schemas.quick_reply import (
|
||||
QuickReplyApprove,
|
||||
QuickReplyCreate,
|
||||
QuickReplyResponse,
|
||||
QuickReplyUpdate,
|
||||
@@ -254,3 +255,84 @@ async def delete_quick_reply(
|
||||
logger.info(f"删除快速回复模板: id={template_id}")
|
||||
|
||||
return success_response(data=None, message="删除成功")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/quick-replies/{id}/approve — 审核通过
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/quick-replies/{template_id}/approve")
|
||||
async def approve_quick_reply(
|
||||
template_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""审核通过快速回复模板。
|
||||
|
||||
将模板状态从 pending_review 改为 approved,版本号 +1。
|
||||
|
||||
Args:
|
||||
template_id: 模板ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的模板
|
||||
"""
|
||||
# 查找模板
|
||||
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
|
||||
result = await db.execute(stmt)
|
||||
template = result.scalars().first()
|
||||
|
||||
if not template:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 审核通过:状态改为 approved,版本号 +1
|
||||
template.status = "approved"
|
||||
template.version += 1
|
||||
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"审核通过快速回复模板: id={template_id}, version={template.version}")
|
||||
|
||||
template_data = QuickReplyResponse.model_validate(template).model_dump()
|
||||
return success_response(data=template_data, message="审核通过")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/quick-replies/{id}/reject — 驳回
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/quick-replies/{template_id}/reject")
|
||||
async def reject_quick_reply(
|
||||
template_id: UUID,
|
||||
body: QuickReplyApprove, # 使用 QuickReplyApprove 作为请求体(驳回不需要额外参数)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""驳回快速回复模板。
|
||||
|
||||
将模板状态从 pending_review 改为 rejected。
|
||||
|
||||
Args:
|
||||
template_id: 模板ID
|
||||
body: 请求体(此接口不需要额外参数)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的模板
|
||||
"""
|
||||
# 查找模板
|
||||
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
|
||||
result = await db.execute(stmt)
|
||||
template = result.scalars().first()
|
||||
|
||||
if not template:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 驳回:状态改为 rejected
|
||||
template.status = "rejected"
|
||||
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"驳回快速回复模板: id={template_id}")
|
||||
|
||||
template_data = QuickReplyResponse.model_validate(template).model_dump()
|
||||
return success_response(data=template_data, message="已驳回")
|
||||
|
||||
+81
-19
@@ -13,6 +13,9 @@ from app.api.conversations import router as conversations_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.agents import router as agents_router
|
||||
from app.api.quick_replies import router as quick_replies_router
|
||||
from app.api.knowledge_base import router as knowledge_base_router
|
||||
from app.api.conversation_annotation import router as annotation_router
|
||||
from app.api.statistics import router as statistics_router
|
||||
from app.api.h5 import router as h5_router
|
||||
from app.api.agent_notes import router as agent_notes_router
|
||||
from app.api.system import router as system_router
|
||||
@@ -22,11 +25,11 @@ from app.api.troubleshooting_templates import router as troubleshooting_template
|
||||
from app.api.employees import router as employees_router
|
||||
from app.api.upload import router as upload_router
|
||||
from app.api.admin_api import router as admin_router
|
||||
from app.api.portal import router as portal_router
|
||||
from app.api.admin_roles import router as admin_roles_router
|
||||
from app.api.admin.security_comparison import router as security_comparison_router
|
||||
from app.api.approval import router as approval_router
|
||||
from app.api.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名
|
||||
# from app.api.knowledge_iteration import router as knowledge_iteration_router # P2-13 知识库自动迭代 (暂未完成)
|
||||
|
||||
# 创建 API 路由器
|
||||
# 所有子路由都会挂载到这个路由器上
|
||||
@@ -73,6 +76,31 @@ api_router.include_router(agents_router, tags=["坐席管理"])
|
||||
# DELETE /api/quick-replies/{id} — 删除模板
|
||||
api_router.include_router(quick_replies_router, tags=["快速回复"])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 知识库 API
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/knowledge — 获取知识库列表
|
||||
# POST /api/knowledge — 创建知识条目
|
||||
# PUT /api/knowledge/{id} — 更新知识条目
|
||||
# DELETE /api/knowledge/{id} — 删除知识条目
|
||||
api_router.include_router(knowledge_base_router, tags=["知识库"])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 会话标注 API
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/annotations — 创建标注
|
||||
# GET /api/annotations/{conversation_id} — 获取会话标注列表
|
||||
api_router.include_router(annotation_router, tags=["会话标注"])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 数据看板统计 API
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/overview — 整体统计概览
|
||||
# GET /api/admin/stats/conversations — 会话趋势统计
|
||||
# GET /api/admin/stats/agents — 坐席绩效统计
|
||||
# GET /api/admin/stats/satisfaction — 满意度统计
|
||||
api_router.include_router(statistics_router, tags=["数据看板"])
|
||||
|
||||
# H5 用户端 API
|
||||
# POST /api/h5/oauth/callback — OAuth2回调
|
||||
# GET /api/h5/user — 获取用户信息
|
||||
@@ -144,12 +172,6 @@ api_router.include_router(upload_router, tags=["文件上传"])
|
||||
# GET /api/admin/search — 全局搜索
|
||||
api_router.include_router(admin_router, tags=["管理后台"])
|
||||
|
||||
# Portal 统一入口 API
|
||||
# GET /api/portal/roles — 获取当前用户角色信息
|
||||
# POST /api/portal/switch-role — 切换当前角色
|
||||
# GET /api/portal/entry/{role} — 获取角色对应的入口 URL
|
||||
api_router.include_router(portal_router, tags=["统一入口"])
|
||||
|
||||
# 管理后台角色管理 API
|
||||
# GET /api/admin/roles — 获取所有角色
|
||||
# POST /api/admin/roles/assign — 分配角色
|
||||
@@ -194,19 +216,16 @@ api_router.include_router(auth_qrcode_router, tags=["扫码登录"])
|
||||
from app.api.high_risk_routes import router as high_risk_routes_router
|
||||
api_router.include_router(high_risk_routes_router, tags=["高危操作"])
|
||||
|
||||
from app.api.mfa import router as mfa_router, admin_router as mfa_admin_router # Phase 2.1 task #17
|
||||
from app.api.otp import router as otp_router # 三端认证重构 AUTH-03
|
||||
|
||||
# MFA 二次认证 API (Phase 2.1 task #17)
|
||||
# GET /api/mfa/status — 查询绑定状态(路由守卫用)
|
||||
# POST /api/mfa/bind/start — 生成 secret + 二维码
|
||||
# POST /api/mfa/bind/confirm — 输入 OTP 完成绑定
|
||||
# POST /api/mfa/verify — 输入 OTP 通过验证(写 Redis 30 分钟)
|
||||
# POST /api/mfa/disable — 用户主动关闭 MFA
|
||||
api_router.include_router(mfa_router, tags=["MFA二次认证"])
|
||||
|
||||
# MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底)
|
||||
# POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA
|
||||
api_router.include_router(mfa_admin_router, tags=["MFA管理(管理员)"])
|
||||
# 统一 OTP 二次认证 API(三端共用,取代原 /mfa/* 与 /admin/mfa/*)
|
||||
# GET /api/auth/otp-status — 查询绑定状态
|
||||
# POST /api/auth/otp-bind — 生成 secret + 二维码
|
||||
# POST /api/auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
|
||||
# POST /api/auth/otp-unbind — 用户主动关闭 OTP
|
||||
# POST /api/auth/otp-admin-reset/{id} — 管理员重置指定员工 OTP
|
||||
# GET /api/auth/otp-admin-users — 管理员查看全部坐席 OTP 绑定状态
|
||||
api_router.include_router(otp_router, tags=["OTP二次认证"])
|
||||
|
||||
# 企微 SSO (v0.7.1 task #85)
|
||||
# GET /api/auth_wecom/sso/init — 企微浏览器 UA 检测后初始化 SSO
|
||||
@@ -220,3 +239,46 @@ api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"])
|
||||
# 权限要求: audit_log:read:all (RBAC 装饰器强制)
|
||||
from app.api.audit_logs import router as audit_logs_router
|
||||
api_router.include_router(audit_logs_router, tags=["审计日志"])
|
||||
|
||||
# 阶段5 自动化闭环 API
|
||||
# POST /itportal/automation/sessions — 创建自动化会话
|
||||
# GET /itportal/automation/sessions — 会话列表
|
||||
# GET /itportal/automation/sessions/{id} — 会话详情
|
||||
# POST /itportal/automation/sessions/{id}/approve — 坐席审批
|
||||
# POST /itportal/automation/sessions/{id}/takeover — 转人工接管
|
||||
# POST /itportal/automation/sessions/by-employee — 员工创建会话
|
||||
# POST /itportal/automation/sessions/{id}/confirm — 员工 H5 确认
|
||||
# POST /itportal/automation/sessions/{id}/feedback — 员工反馈
|
||||
# GET /itportal/automation/admin/scenarios — 场景配置列表
|
||||
# PUT /itportal/automation/admin/scenarios/{key} — 更新场景(OTP)
|
||||
# GET /itportal/automation/admin/rule-versions — 规则版本
|
||||
# GET /itportal/automation/admin/metrics — 看板指标
|
||||
from app.api.automation import router as automation_router
|
||||
api_router.include_router(automation_router, tags=["自动化闭环"])
|
||||
|
||||
# 管理员用户管理 API
|
||||
# GET /api/admin/users — 获取管理员列表
|
||||
# POST /api/admin/users — 创建管理员
|
||||
# GET /api/admin/users/{id} — 获取管理员详情
|
||||
# PUT /api/admin/users/{id} — 更新管理员
|
||||
# DELETE /api/admin/users/{id} — 删除管理员
|
||||
# POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
from app.api.admin_users import router as admin_users_router
|
||||
api_router.include_router(admin_users_router, tags=["管理员用户管理"])
|
||||
|
||||
# 满意度评价 API (P1-25)
|
||||
# POST /api/conversation/{id}/evaluate — 提交评价
|
||||
# GET /api/conversation/{id}/evaluation — 获取会话评价
|
||||
# GET /api/evaluations/stats — 评价统计
|
||||
# POST /api/conversations/{id}/send-evaluation-invite — 发送评价邀请
|
||||
from app.api.evaluations import router as evaluations_router
|
||||
api_router.include_router(evaluations_router, tags=["满意度评价"])
|
||||
|
||||
# 知识库自动迭代 API (P2-13)
|
||||
# POST /api/admin/knowledge-iteration/analyze — 触发分析
|
||||
# GET /api/admin/knowledge-iteration/suggestions — 获取建议列表
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id} — 获取建议详情
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve — 审核通过
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject — 审核拒绝
|
||||
# GET /api/admin/knowledge-iteration/stats — 获取统计
|
||||
# api_router.include_router(knowledge_iteration_router, prefix="/admin/knowledge-iteration", tags=["知识库自动迭代"]) # 暂未完成
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 数据看板 API
|
||||
# =============================================================================
|
||||
# 说明:数据统计接口,为管理后台数据看板提供数据支持
|
||||
# 1. GET /api/admin/stats/overview — 获取整体统计概览
|
||||
# 2. GET /api/admin/stats/conversations — 会话趋势统计
|
||||
# 3. GET /api/admin/stats/agents — 坐席绩效统计
|
||||
# 4. GET /api/admin/stats/satisfaction — 满意度统计
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select, and_, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.conversation_evaluation import ConversationEvaluation
|
||||
from app.models.conversation_annotation import ConversationAnnotation
|
||||
from app.models.message import Message
|
||||
from app.utils.response import success_response
|
||||
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def get_date_range(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""解析日期范围参数。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
|
||||
Returns:
|
||||
tuple: (开始时间, 结束时间)
|
||||
"""
|
||||
if end_date:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
end_dt = datetime.now() + timedelta(days=1)
|
||||
|
||||
if start_date:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
else:
|
||||
start_dt = end_dt - timedelta(days=30) # 默认30天
|
||||
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/overview — 整体统计概览
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/overview")
|
||||
async def get_overview_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取整体统计概览。
|
||||
|
||||
包含:总会话数、待处理会话数、已解决会话数、平均响应时间、满意度等。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 整体统计数据
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 总会话数
|
||||
stmt_total = select(func.count(Conversation.id)).where(
|
||||
and_(
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_total)
|
||||
total_conversations = result.scalar() or 0
|
||||
|
||||
# 待处理会话数(状态为 queued 或 serving)
|
||||
stmt_pending = select(func.count(Conversation.id)).where(
|
||||
and_(
|
||||
Conversation.status.in_(["queued", "serving"]),
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_pending)
|
||||
pending_conversations = result.scalar() or 0
|
||||
|
||||
# 已解决会话数(状态为 resolved)
|
||||
stmt_resolved = select(func.count(Conversation.id)).where(
|
||||
and_(
|
||||
Conversation.status == "resolved",
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_resolved)
|
||||
resolved_conversations = result.scalar() or 0
|
||||
|
||||
# 计算满意度(已评价会话的平均评分)
|
||||
stmt_satisfaction = select(
|
||||
func.avg(ConversationEvaluation.score),
|
||||
func.count(ConversationEvaluation.id),
|
||||
).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_satisfaction)
|
||||
satisfaction_row = result.first()
|
||||
avg_satisfaction = float(satisfaction_row[0]) if satisfaction_row[0] else 0.0
|
||||
evaluated_count = satisfaction_row[1] or 0
|
||||
|
||||
# 计算平均响应时间(第一条坐席消息与第一条消息的时间差)
|
||||
# 简化计算:resolved会话的平均解决时长
|
||||
stmt_duration = select(func.avg(
|
||||
func.extract('epoch', Conversation.updated_at) - func.extract('epoch', Conversation.created_at)
|
||||
)).where(
|
||||
and_(
|
||||
Conversation.status == "resolved",
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_duration)
|
||||
avg_duration_seconds = result.scalar() or 0
|
||||
avg_duration_minutes = avg_duration_seconds / 60 if avg_duration_seconds else 0
|
||||
|
||||
data = {
|
||||
"total_conversations": total_conversations,
|
||||
"pending_conversations": pending_conversations,
|
||||
"resolved_conversations": resolved_conversations,
|
||||
"resolution_rate": round(resolved_conversations / total_conversations * 100, 1) if total_conversations > 0 else 0,
|
||||
"avg_satisfaction": round(avg_satisfaction, 2),
|
||||
"evaluated_count": evaluated_count,
|
||||
"avg_duration_minutes": round(avg_duration_minutes, 1),
|
||||
}
|
||||
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/conversations — 会话趋势统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/conversations")
|
||||
async def get_conversation_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取会话趋势统计。
|
||||
|
||||
按天统计每日会话数、解决数。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 趋势数据列表
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 按天统计会话数
|
||||
stmt = select(
|
||||
func.date(Conversation.created_at).label("date"),
|
||||
func.count(Conversation.id).label("total"),
|
||||
).where(
|
||||
and_(
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
).group_by(
|
||||
func.date(Conversation.created_at)
|
||||
).order_by(
|
||||
func.date(Conversation.created_at)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 转换为日期+统计的格式
|
||||
trend_data = []
|
||||
for row in rows:
|
||||
date_val = row.date
|
||||
if isinstance(date_val, datetime):
|
||||
date_str = date_val.strftime("%Y-%m-%d")
|
||||
else:
|
||||
date_str = str(date_val)
|
||||
|
||||
trend_data.append({
|
||||
"date": date_str,
|
||||
"total": row.total,
|
||||
})
|
||||
|
||||
return success_response(data={"items": trend_data})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/agents — 坐席绩效统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/agents")
|
||||
async def get_agent_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取坐席绩效统计。
|
||||
|
||||
统计各坐席的处理会话数、解决数、平均响应时间。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 坐席绩效列表
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 统计各坐席的会话数
|
||||
stmt = select(
|
||||
Conversation.assigned_agent_id,
|
||||
func.count(Conversation.id).label("total"),
|
||||
func.sum(
|
||||
func.case((Conversation.status == "resolved", 1), else_=0)
|
||||
).label("resolved"),
|
||||
).where(
|
||||
and_(
|
||||
Conversation.assigned_agent_id.isnot(None),
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
).group_by(
|
||||
Conversation.assigned_agent_id
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 获取坐席信息
|
||||
agent_ids = [row[0] for row in rows if row[0]]
|
||||
agent_stmt = select(Agent.id, Agent.name).where(Agent.id.in_(agent_ids))
|
||||
agent_result = await db.execute(agent_stmt)
|
||||
agent_map = {a.id: a.name for a in agent_result.scalars().all()}
|
||||
|
||||
# 转换为坐席绩效数据
|
||||
agent_data = []
|
||||
for row in rows:
|
||||
if not row[0]:
|
||||
continue
|
||||
agent_id = row[0]
|
||||
agent_data.append({
|
||||
"agent_id": agent_id,
|
||||
"agent_name": agent_map.get(agent_id, "未知"),
|
||||
"total_conversations": row[1],
|
||||
"resolved_conversations": row[2] or 0,
|
||||
"resolution_rate": round((row[2] or 0) / row[1] * 100, 1) if row[1] > 0 else 0,
|
||||
})
|
||||
|
||||
# 按处理数排序
|
||||
agent_data.sort(key=lambda x: x["total_conversations"], reverse=True)
|
||||
|
||||
return success_response(data={"items": agent_data})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/satisfaction — 满意度统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/satisfaction")
|
||||
async def get_satisfaction_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取满意度统计。
|
||||
|
||||
统计评分分布、各表情占比。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 满意度统计数据
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 评分分布统计
|
||||
stmt = select(
|
||||
ConversationEvaluation.score,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
)
|
||||
).group_by(
|
||||
ConversationEvaluation.score
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 评分分布
|
||||
score_distribution = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
for row in rows:
|
||||
if row[0] in score_distribution:
|
||||
score_distribution[row[0]] = row[1]
|
||||
|
||||
# 表情分布
|
||||
stmt_emoji = select(
|
||||
ConversationEvaluation.emoji,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
ConversationEvaluation.emoji.isnot(None),
|
||||
)
|
||||
).group_by(
|
||||
ConversationEvaluation.emoji
|
||||
)
|
||||
|
||||
result = await db.execute(stmt_emoji)
|
||||
emoji_rows = result.all()
|
||||
|
||||
emoji_distribution = {}
|
||||
for row in emoji_rows:
|
||||
if row[0]:
|
||||
emoji_distribution[row[0]] = row[1]
|
||||
|
||||
# 计算平均分
|
||||
stmt_avg = select(func.avg(ConversationEvaluation.score)).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_avg)
|
||||
avg_score = result.scalar() or 0
|
||||
|
||||
data = {
|
||||
"avg_score": round(float(avg_score), 2),
|
||||
"total_evaluated": sum(score_distribution.values()),
|
||||
"score_distribution": [
|
||||
{"score": k, "count": v} for k, v in sorted(score_distribution.items())
|
||||
],
|
||||
"emoji_distribution": [
|
||||
{"emoji": k, "count": v} for k, v in emoji_distribution.items()
|
||||
],
|
||||
}
|
||||
|
||||
return success_response(data=data)
|
||||
@@ -126,6 +126,10 @@ async def check_emergency_role(
|
||||
|
||||
# 方式 1:企微标签检测
|
||||
tag_id = getattr(settings, "wecom_agent_tag_id", None)
|
||||
user_info = None
|
||||
role = "user"
|
||||
method = "default"
|
||||
|
||||
if tag_id:
|
||||
try:
|
||||
access_token = await wecom_service.get_access_token()
|
||||
@@ -144,14 +148,12 @@ async def check_emergency_role(
|
||||
]
|
||||
if userid in user_ids:
|
||||
logger.info(f"标签检测: userid={userid} 是坐席")
|
||||
return success_response(
|
||||
{"role": "agent", "userid": userid, "method": "tag"}
|
||||
)
|
||||
role = "agent"
|
||||
method = "tag"
|
||||
else:
|
||||
logger.info(f"标签检测: userid={userid} 是员工")
|
||||
return success_response(
|
||||
{"role": "user", "userid": userid, "method": "tag"}
|
||||
)
|
||||
role = "user"
|
||||
method = "tag"
|
||||
else:
|
||||
logger.warning(
|
||||
f"标签 API 失败: errcode={result.get('errcode')}, "
|
||||
@@ -166,16 +168,50 @@ async def check_emergency_role(
|
||||
agent_ids = [x.strip() for x in hardcoded.split(",") if x.strip()]
|
||||
if userid in agent_ids:
|
||||
logger.info(f"硬编码名单: userid={userid} 是坐席")
|
||||
return success_response(
|
||||
{"role": "agent", "userid": userid, "method": "hardcoded"}
|
||||
)
|
||||
role = "agent"
|
||||
method = "hardcoded"
|
||||
else:
|
||||
return success_response(
|
||||
{"role": "user", "userid": userid, "method": "hardcoded"}
|
||||
)
|
||||
role = "user"
|
||||
method = "hardcoded"
|
||||
|
||||
# 方式 3:默认 user
|
||||
logger.info(f"未配置检测方式, userid={userid} 默认 user")
|
||||
return success_response(
|
||||
{"role": "user", "userid": userid, "method": "default"}
|
||||
)
|
||||
# 获取用户详细信息(名称、头像)- 添加超时,避免长时间阻塞
|
||||
user_info = None
|
||||
try:
|
||||
import asyncio
|
||||
import httpx
|
||||
# 设置获取 access_token 的超时时间
|
||||
access_token = await asyncio.wait_for(
|
||||
wecom_service.get_access_token(),
|
||||
timeout=2.0 # 2秒超时
|
||||
)
|
||||
user_url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
user_resp = await client.get(user_url)
|
||||
user_result = user_resp.json()
|
||||
if user_result.get("errcode", 0) == 0:
|
||||
user_info = {
|
||||
"name": user_result.get("name", ""),
|
||||
"avatar": user_result.get("avatar", ""),
|
||||
"department": "" # 简化:暂不获取部门名称
|
||||
}
|
||||
logger.info(f"获取用户信息成功: userid={userid}, name={user_info['name']}")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"获取用户信息超时: userid={userid}")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取用户信息失败: {e}")
|
||||
|
||||
# 构建返回数据
|
||||
response_data = {
|
||||
"role": role,
|
||||
"userid": userid,
|
||||
"method": method
|
||||
}
|
||||
# 添加用户信息(如果有)
|
||||
if user_info:
|
||||
response_data.update(user_info)
|
||||
|
||||
# 方式 3:默认 user(当未配置检测方式时)
|
||||
if method == "default":
|
||||
logger.info(f"未配置检测方式, userid={userid} 默认 user")
|
||||
|
||||
return success_response(response_data)
|
||||
|
||||
Reference in New Issue
Block a user