WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+367
View File
@@ -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
View File
@@ -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, # 复用 AgentLoginotp_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,
})
+50 -11
View File
@@ -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(
+197 -24
View File
@@ -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)}")
+448
View File
@@ -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})
+33 -6
View File
@@ -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
View File
@@ -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),
})
+1 -1
View File
@@ -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
+349
View File
@@ -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
View File
@@ -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静默授权URLsnsapi_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 — 获取审批流程链接
# --------------------------------------------------------------------------
+246
View File
@@ -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})
+266
View File
@@ -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),
}
+364
View File
@@ -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 对齐):
# - 复用 MFAServicepyotp + 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_idemployee_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_secretmfa_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 保持 Falsemfa_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)
-262
View File
@@ -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
+82
View File
@@ -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
View File
@@ -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=["知识库自动迭代"]) # 暂未完成
+398
View File
@@ -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)
+53 -17
View File
@@ -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)