v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化

This commit is contained in:
Simon
2026-07-17 23:08:59 +08:00
parent 5a77a89ab1
commit 3ed86d5fb3
181 changed files with 19738 additions and 2655 deletions
+450 -46
View File
@@ -37,6 +37,9 @@ from app.services.routing_service import (
)
from app.services.vision_service import VisionService
from app.services.ws_manager import manager as ws_manager
from app.services.asset_recommend_service import get_asset_recommend_service
from app.services.employee_profile_service import get_employee_profile_service
from app.api.approval import APPROVAL_TEMPLATES
logger = logging.getLogger(__name__)
@@ -213,6 +216,71 @@ async def _enrich_image_content(
return "\n".join(parts)
async def _persist_and_push_solution(
db,
conversation,
employee_id: str,
solution,
):
"""处理图谱命中的解决方案(图谱查询结果)
做什么:
1. 创建AI回复消息记录(图谱命中的解决方案)
2. 通过WS推送给员工端
为什么:图谱命中的解决方案直接返回,不需要调用Dify
Args:
db: 数据库会话
conversation: 会话对象
employee_id: 员工ID
solution: SolutionResult图谱查询结果
"""
from app.models.message import Message
from app.services.ws_manager import manager as ws_manager
# 1. 创建AI回复消息记录
message = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="graph",
content=solution.solution,
msg_type="text",
)
db.add(message)
# 更新会话计数
conversation.ai_substantive_reply_count += 1
conversation.updated_at = datetime.now()
await db.commit()
# 2. 构建推送数据
msg_data = {
"id": str(message.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "graph",
"sender_name": "智能助手",
"content": solution.solution,
"msg_type": "text",
"created_at": message.created_at.isoformat(),
"reply_source": "graph_hit", # 标记来源为图谱命中
}
# 3. 通过WS推送给员工端
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": msg_data,
})
logger.info(
f"图谱命中推送成功: employee={employee_id}, "
f"solution={solution.action_name}"
)
except Exception as push_err:
logger.error(f"图谱命中推送失败: {push_err}")
async def _persist_and_push(
db,
conversation: Conversation,
@@ -328,6 +396,12 @@ async def _persist_and_push_structured(
result: get_structured_reply() 返回的结构化结果
"""
text = result.get("text", "")
# ★ 防御性类型保护:确保 content 始终是 String
# 如果 Dify 返回的 text 是 dict/listWS 推送后前端会显示 [object Object]
if not isinstance(text, str):
import json as _json
text = _json.dumps(text, ensure_ascii=False) if text else ""
logger.warning(f"_persist_and_push_structured: text 非 String 类型,已转换: {text[:80]}...")
action = result.get("action")
options = result.get("options")
hit = result.get("hit", False)
@@ -352,18 +426,47 @@ async def _persist_and_push_structured(
should_transfer = not hit
# 确定消息类型
if is_structured and (options or action):
# v2.4 修复:有 action(审批卡片)时,强制设置为 ai_structured
# 确保前端能渲染审批卡片入口,不依赖 Dify 返回的 is_structured 字段
if action or options or is_structured:
msg_type = "ai_structured"
else:
msg_type = "text"
# v2.5 调试日志
logger.info(f"[DEBUG] msg_type = {msg_type}, action = {bool(action)}, options = {bool(options)}, is_structured = {is_structured}")
# 构建 extra_data(存储 options 和 action 供前端渲染)
extra_data = {}
if options:
extra_data["options"] = options
# 为审批卡片注入标准化 card_data(替换原有的分散匹配逻辑)
if action:
approval_type = action.get("approval_type")
title = action.get("title")
# v3.0 重构:委托 ApprovalMatcher 统一完成模板匹配 + 卡片构建
from app.services.approval_matcher import get_approval_matcher
matcher = get_approval_matcher()
matched_card = matcher.match_and_build_card(approval_type, title)
if matched_card:
# 注入 URL 供 approve-direct-card 兼容路径使用
options = matched_card.get("options", [])
if options:
action["url"] = options[0].get("url", "")
action["card_data"] = matched_card
logger.info(f"[ApprovalMatcher] 匹配成功: {approval_type} -> card_type={matched_card.get('card_type')}")
else:
logger.warning(f"[ApprovalMatcher] 匹配失败: approval_type={approval_type}, title={title}")
extra_data["action"] = action
# ★ 调试日志:打印推送到前端的 extra_data 内容
logger.info(f"[DEBUG] 推送到前端的 extra_data: {extra_data}")
if extra_data.get("action"):
logger.info(f"[DEBUG] extra_data.action.approval_type = {extra_data['action'].get('approval_type')}")
# 1. 存 AI 消息
ai_message = Message(
conversation_id=conversation.id,
@@ -397,48 +500,86 @@ async def _persist_and_push_structured(
await db.commit()
# 3. 推 ai_reply 给员工端(聊天气泡:text + options
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": text,
"msg_type": msg_type,
"extra_data": extra_data if extra_data else None,
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
# Phase 6A: 诊断阶段(前端可据此调整 UI/提示)
"diagnosis_stage": diagnosis_stage,
},
})
# 添加异常处理,避免整体失败
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": text,
"msg_type": msg_type,
"extra_data": extra_data if extra_data else None,
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
# Phase 6A: 诊断阶段(前端可据此调整 UI/提示)
"diagnosis_stage": diagnosis_stage,
},
})
except Exception as emp_err:
# 员工端推送失败不应该导致整个任务失败
logger.warning(f"员工端 AI 回复推送失败(不影响坐席端): {emp_err}")
# 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空时)
# 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空且不是审批类型时)
# 与 ai_reply 同一时刻发出 → 零时间差到达
if action:
# v2.3 修改:审批类型只推送到消息气泡(左边),不推送到侧边栏(右边)
if action and not action.get("approval_type"):
# 为审批卡片注入运维平台跳转URL(实现免登录跳转)
approval_type = action.get("approval_type")
action_url = ""
location = "运维平台"
# 优先精确匹配:直接用 approval_type 查找模板
if approval_type and approval_type in APPROVAL_TEMPLATES:
template = APPROVAL_TEMPLATES[approval_type]
action_url = template.get("url", "")
location = template.get("location", "运维平台")
# 关键字匹配:当精确匹配失败时,通过关键字查找模板
# Dify返回的 approval_type 可能是中文分类名(如"账号权限申请"、"VPN账号申请"
elif approval_type:
for template_id, template in APPROVAL_TEMPLATES.items():
keywords = template.get("keywords", [])
# 检查 approval_type 是否包含任意一个关键字
if any(kw.lower() in approval_type.lower() for kw in keywords):
action_url = template.get("url", "")
location = template.get("location", "运维平台")
break
# v2.2 新增:根据 Dify 返回的 approval_type 设置 filtered_options
filtered_options = []
if approval_type:
filtered_options = [approval_type]
recommend_data = {
"recommend_id": f"rec_{ai_message.id}",
"card_type": action.get("type", "approval_card"),
"title": action.get("title", ""),
"description": action.get("description", ""),
"approval_type": action.get("approval_type"),
"filtered_options": filtered_options, # v2.2: 精确匹配的选项
"action_url": action_url, # 运维平台跳转URL
"action_label": f"打开{location}" if action_url else "打开审批表单", # 按钮文字
"location": location, # 平台名称
"confidence": action.get("confidence", 0.85),
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
}
await ws_manager.broadcast_to_employees([employee_id], {
"type": "dynamic_recommend",
"data": recommend_data,
})
logger.info(
f"动态推荐已推送: employee={employee_id}, "
f"card_type={recommend_data['card_type']}, "
f"title={recommend_data['title']}"
)
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "dynamic_recommend",
"data": recommend_data,
})
logger.info(
f"动态推荐已推送: employee={employee_id}, "
f"card_type={recommend_data['card_type']}, "
f"title={recommend_data['title']}"
)
except Exception as rec_err:
logger.warning(f"员工端动态推荐推送失败: {rec_err}")
# 5. 广播坐席端(new_message + conversation_updated
try:
@@ -711,6 +852,147 @@ async def _handle_routing(
return True
async def _enrich_with_last_ai_context(db, conversation_id: str, content: str) -> str:
"""为简短回复拼接对话上下文,弥补 Dify 工作流缺少「对话历史」节点。
v2.3 改进(相对于 v2.2):
- 移除 15 字符硬限制 → 50 字符宽松阈值(问句/换行/长消息自动跳过)
- 查询最近 10 条消息(用户+AI)→ 构建完整对话摘要,含用户原始问题
- 不再依赖 extra_data.options 判断,对所有简短回复尝试拼接
- 跳过刚保存的当前消息避免重复(当前内容已作为 query 单独传给 Dify)
触发条件:消息不含问号/换行、长度 <= 50 字符 → 可能是选项选择/简短回答。
后续:Dify 工作流配置对话历史节点后,可将 `MAX_CONTEXT_LENGTH` 设为 0 来禁用此修复。
返回:拼接后的消息(如果不需要拼接则返回原内容)
"""
# 宽松的启发式判断:不含问号、不含换行、<= 50 字符 → 可能是简短回答
if "?" in content or "" in content or "\n" in content or len(content) > 50:
return content
try:
# 1. 查询最近 10 条消息(按时间倒序索取最新),含用户和 AI
stmt = (
select(Message.content, Message.sender_type, Message.created_at)
.where(Message.conversation_id == conversation_id)
.order_by(Message.created_at.desc())
.limit(10)
)
result = await db.execute(stmt)
rows = list(result.all())
if not rows or len(rows) < 2:
return content # 消息太少,无法构建有意义上下文
# 2. 反转顺序:最早 → 最新
rows.reverse()
# 3. 跳过最后一条员工消息 → 即刚刚保存的当前消息(避免在上下文中重复)
# content 已作为 query 单独发给 Dify,不应出现在上下文中
if rows and rows[-1][1] == "employee":
rows = rows[:-1]
if len(rows) < 2:
return content # 去掉当前消息后没剩几条,不拼接
# 4. 构建对话摘要(最多保留最近 8 条,避免 prompt 过长)
context_lines = []
for row_text, row_sender, _ in rows[-8:]:
if not row_text:
continue
role = "用户" if row_sender == "employee" else "AI助手"
context_lines.append(f"{role}: {row_text}")
if not context_lines:
return content
context = "\n".join(context_lines)
return (
f"【对话上下文】\n{context}\n\n"
f"请根据以上对话历史回答用户的以下消息:{content}"
)
except Exception as e:
logger.warning(f"上下文拼接失败,使用原消息: {e}")
return content
async def _push_asset_recommends(
db,
employee_id: str,
message: str,
dify_result: dict,
):
"""v3.0 资产推荐推送 - 独立于对话的运维触达通道
功能:
1. L1: 从关键词匹配资产(与当前问题相关)
2. L2: 从画像触发运维提醒(与问题无关)
3. L3: 角色通用资源推荐
Args:
db: 数据库会话
employee_id: 员工 ID
message: 用户消息(用于关键词匹配)
dify_result: Dify 返回结果(包含 intent 等信息)
"""
try:
asset_service = get_asset_recommend_service()
profile_service = get_employee_profile_service()
# 1. L1: 关键词匹配(从用户消息中提取关键词)
l1_recs = asset_service.match_keywords(message)
# 2. L2+L3: 画像匹配(需要获取员工画像)
# 为避免每次都调用第三方 API,先尝试获取画像
# 画像获取失败时只推送 L1
profile = None
try:
profile = await profile_service.get_profile(employee_id)
profile_dict = {
'huorong_version': profile.huorong_version,
'huorong_virusdb_date': profile.huorong_virusdb_date,
'huorong_offline_days': profile.huorong_offline_days,
'unionsoft_patches_missing': profile.unionsoft_patches_missing,
'unionsoft_violations': profile.unionsoft_violations,
}
l2_recs = asset_service.match_profile_triggers(profile_dict)
# L3: 角色通用推荐
role = profile.position or ''
l3_recs = asset_service.get_by_role(role)
for rec in l3_recs:
rec.layer = 'L3'
rec.layer_label = '常用资源'
rec.relevance = 'low'
except Exception as e:
logger.warning(f"[AssetRecommend] 获取画像失败: {e}")
l2_recs = []
l3_recs = []
# 3. 合并所有推荐(去重)
all_recs = l1_recs + l2_recs + l3_recs
if not all_recs:
logger.debug(f"[AssetRecommend] 无推荐: employee={employee_id}")
return
# 4. 构建 WS 消息并推送(添加异常处理避免影响主流程)
try:
ws_msg = asset_service.build_ws_message(all_recs)
await ws_manager.broadcast_to_employees([employee_id], ws_msg)
logger.info(
f"[AssetRecommend] 已推送: employee={employee_id}, "
f"L1={len(l1_recs)}, L2={len(l2_recs)}, L3={len(l3_recs)}"
)
except Exception as ws_err:
logger.warning(f"[AssetRecommend] WS推送失败(不影响主流程): {ws_err}")
except Exception as e:
logger.error(f"[AssetRecommend] 推送失败: {e}", exc_info=True)
# 资产推荐失败不影响主对话流程
async def process_h5_ai_reply(
conversation_id: str,
employee_id: str,
@@ -753,9 +1035,19 @@ async def process_h5_ai_reply(
factory = _get_session_factory()
async with factory() as db:
try:
conversation = await db.get(Conversation, conversation_id)
# 防御:会话刚创建时可能事务未提交,最多重试 3 次(每次 0.5s)
conversation = None
for attempt in range(3):
conversation = await db.get(Conversation, conversation_id)
if conversation:
break
if attempt < 2:
await asyncio.sleep(0.5)
# 刷新 session 以看到已提交的数据
await db.rollback()
if not conversation:
logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}")
logger.warning(f"后台 AI 任务:会话不存在(重试3次后) {conversation_id}")
return
# === BYOD 关键词拦截(仅文本消息)===
@@ -814,15 +1106,62 @@ async def process_h5_ai_reply(
# 降级:使用原始 contentAI 会收到 "[图片] 截图" 这样的占位符
# Dify 会回复"我收到了您的截图,请描述一下问题"
# === ★ v2.3 临时修复:Dify 对话历史缺失,为简短回复拼接上下文 ===
# 问题:Dify 工作流未配置「对话历史」节点,conversation_id 传递了但 LLM 看不到历史
# 改进:查询最近 10 条消息(用户+AI)构建完整对话摘要,含用户原始问题
# 触发:消息短(<=50字符)、无问号、无换行 → 拼接后传给 Dify
# 后续:Dify 工作流配置对话历史后可移除此修复
enriched_content = await _enrich_with_last_ai_context(
db, conversation_id, enriched_content
)
# === ★ Neo4j 知识图谱查询(新增 v3.0===
# 做什么:在调用 Dify 之前先查询知识图谱
# 为什么:简单问题可以直接从图谱返回解决方案,响应更快
# 效果:简单问题响应从 3-15秒 → 毫秒级
logger.info(f"图谱检查: msg_type={msg_type}, content_len={len(enriched_content) if enriched_content else 0}")
if msg_type == "text" and enriched_content:
try:
from app.services.graph_query_service import get_graph_query_service
from app.services.neo4j_client import get_neo4j_client
neo4j_client = await get_neo4j_client()
if neo4j_client:
graph_service = await get_graph_query_service(neo4j_client)
if graph_service:
solution = await graph_service.find_solution_by_question(
enriched_content
)
else:
solution = None
if solution:
logger.info(
f"图谱命中: question={enriched_content[:30]}, "
f"solution={solution.action_name}"
)
# 直接返回图谱解决方案,跳过 Dify 调用
await _persist_and_push_solution(
db, conversation, employee_id, solution
)
return
except Exception as graph_err:
# 图谱查询失败不阻断,继续原有 Dify 流程
import traceback
logger.warning(f"图谱查询异常(降级继续): {graph_err}\n{traceback.format_exc()}")
# === ★ v2.0 结构化 AI 回复(替代流式)===
# 1. 立即推送 "正在思考..." 指示器
# 同时推给员工(气泡动画)和坐席(状态指示)
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_thinking",
"data": {
"conversation_id": conversation_id,
},
})
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_thinking",
"data": {
"conversation_id": conversation_id,
},
})
except Exception as thinking_err:
logger.warning(f"推送 AI 思考指示器失败: {thinking_err}")
# 坐席端也通知:AI 正在处理此会话的消息
try:
await ws_manager.broadcast({
@@ -858,15 +1197,40 @@ async def process_h5_ai_reply(
result = await asyncio.wait_for(
ai_handler.ai_service.get_structured_reply(
message=enriched_content,
conversation_id=dify_conversation_id,
conversation_id=dify_conversation_id or conversation.dify_conversation_id,
user_id=employee_id,
),
timeout=30,
)
except asyncio.TimeoutError:
# 30 秒硬超时 → 建议转人工
# v3.0: Dify 超时 → 关键词降级匹配
thinking_task.cancel()
logger.warning(f"Dify 30 秒超时: conversation={conversation_id}")
logger.warning(f"Dify 30 秒超时: conversation={conversation_id},尝试关键词降级")
from app.services.approval_matcher import get_approval_matcher
matcher = get_approval_matcher()
matched_card = matcher.match_by_keywords(content)
if matched_card:
logger.info(f"[Fallback] 关键词降级成功: {matched_card.get('title')}")
fallback_result = {
"text": f"我来帮您提交{matched_card.get('title', '审批')},请点击下方卡片。",
"action": {"card_data": matched_card, "url": matched_card.get("options", [{}])[0].get("url", "")} if matched_card.get("options") else None,
"options": None,
"hit": True,
"conversation_id": conversation.dify_conversation_id,
"is_structured": True,
"diagnosis_stage": None,
"response_time_ms": 0,
"source": "keyword_fallback",
}
try:
await _persist_and_push_structured(db, conversation, employee_id, fallback_result)
return
except Exception as fallback_err:
logger.error(f"[Fallback] 降级推送失败: {fallback_err}")
# 降级也失败 → 建议转人工
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply_failed",
"data": {
@@ -883,13 +1247,53 @@ async def process_h5_ai_reply(
except asyncio.CancelledError:
pass
# v3.1: Dify 返回但无 action(如诊断 escalate/回复"AI服务暂时不可用")→ 关键词降级
# 这是对 v3.0 的补充:v3.0 只在 asyncio.TimeoutError 触发降级,但 Dify LLM 自身可能误判
if not result.get("action"):
from app.services.approval_matcher import get_approval_matcher
matcher = get_approval_matcher()
matched_card = matcher.match_by_keywords(content)
if matched_card:
logger.info(f"[Fallback-v3.1] Dify 无action但关键词命中: {matched_card.get('title')}")
result = {
"text": f"我来帮您提交{matched_card.get('title', '审批')},请点击下方卡片。",
"action": {
"type": "approval_card",
"card_data": matched_card,
"url": matched_card.get("options", [{}])[0].get("url", "") if matched_card.get("options") else "",
},
"options": None,
"hit": True,
"conversation_id": result.get("conversation_id") or conversation.dify_conversation_id,
"is_structured": True,
"diagnosis_stage": "recommending",
"response_time_ms": result.get("response_time_ms", 0),
"source": "keyword_fallback_v3",
}
# 5. 持久化 + 双 WS 推送(ai_reply + dynamic_recommend
await _persist_and_push_structured(
db, conversation, employee_id, result,
)
# 添加单独异常处理,避免影响主流程
try:
await _persist_and_push_structured(
db, conversation, employee_id, result,
)
except Exception as persist_err:
logger.error(f"[Persist] AI回复持久化失败: {persist_err}", exc_info=True)
# 6. v3.0 资产推荐推送(L1/L2/L3 分层)
# 独立于对话的运维触达通道
try:
await _push_asset_recommends(
db, employee_id, content, result,
)
except Exception as asset_err:
logger.error(f"[Asset] 资产推荐推送失败: {asset_err}", exc_info=True)
except Exception as e:
logger.error(f"后台 AI 任务异常: {e}", exc_info=True)
import traceback
# 记录完整的堆栈跟踪信息
tb_str = traceback.format_exc()
logger.error(f"后台 AI 任务异常: {e}\n堆栈跟踪:\n{tb_str}", exc_info=True)
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply_failed",
+147
View File
@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
"""
资产推荐定时任务
功能:
1. 每日运维提醒推送(L2
2. 画像缓存预热
3. 新员工欢迎推送(L3
"""
import asyncio
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
logger = logging.getLogger(__name__)
# 定时任务调度器实例
scheduler = AsyncIOSScheduler()
def setup_scheduled_tasks():
"""配置定时任务"""
# 每日 9:00 运维提醒
scheduler.add_job(
daily_maintenance_push,
trigger=CronTrigger(hour=9, minute=0),
id='daily_maintenance_push',
name='每日运维提醒推送',
replace_existing=True
)
# 每小时画像缓存刷新
scheduler.add_job(
hourly_profile_sync,
trigger=CronTrigger(minute=0),
id='hourly_profile_sync',
name='每小时员工画像同步',
replace_existing=True
)
# 每天 8:55 检查新员工
scheduler.add_job(
check_new_employees,
trigger=CronTrigger(hour=8, minute=55),
id='check_new_employees',
name='新员工欢迎检查',
replace_existing=True
)
logger.info(f"[Scheduler] 已配置 {len(scheduler.get_jobs())} 个定时任务")
async def daily_maintenance_push():
"""每日运维提醒推送"""
logger.info("[Scheduler] 开始执行每日运维提醒推送")
from app.services.asset_recommend_service import get_asset_recommend_service
from app.services.employee_profile_service import get_employee_profile_service
asset_service = get_asset_recommend_service()
profile_service = get_employee_profile_service()
# 获取全部员工(分页)
# TODO: 实现分页获取员工列表
# page = 1
# while True:
# employees = await get_employees_paginated(page, 100)
# if not employees:
# break
#
# for emp in employees:
# try:
# profile = await profile_service.get_profile(emp.id)
# profile_dict = {
# 'huorong_version': profile.huorong_version,
# 'huorong_virusdb_date': profile.huorong_virusdb_date,
# 'huorong_offline_days': profile.huorong_offline_days,
# 'unionsoft_patches_missing': profile.unionsoft_patches_missing,
# 'unionsoft_violations': profile.unionsoft_violations,
# }
# l2_recs = asset_service.match_profile_triggers(profile_dict)
#
# if l2_recs:
# ws_msg = asset_service.build_ws_message(l2_recs)
# from app.services.ws_manager import manager as ws_manager
# await ws_manager.broadcast_to_employees([emp.id], ws_msg)
#
# except Exception as e:
# logger.error(f"[Scheduler] 推送失败: {emp.id}, {e}")
#
# page += 1
logger.info("[Scheduler] 每日运维提醒推送完成 (TODO: 实现员工列表获取)")
async def hourly_profile_sync():
"""每小时同步员工画像缓存"""
logger.info("[Scheduler] 开始同步员工画像缓存")
try:
profile_service = get_employee_profile_service()
deleted = await profile_service.clear_expired_cache()
logger.info(f"[Scheduler] 画像缓存同步完成,清理 {deleted}")
except Exception as e:
logger.error(f"[Scheduler] 画像缓存同步失败: {e}")
async def check_new_employees():
"""检查新员工并发送欢迎"""
logger.info("[Scheduler] 检查新员工")
# TODO: 实现新员工检测逻辑
# 获取过去 24 小时入职的员工
# new_employees = await get_new_employees(days=1)
#
# for emp in new_employees:
# asset_service = get_asset_recommend_service()
# role_recs = asset_service.get_by_role('new_employee')
#
# if role_recs:
# ws_msg = asset_service.build_ws_message(role_recs)
# from app.services.ws_manager import manager as ws_manager
# await ws_manager.broadcast_to_employees([emp.id], ws_msg)
logger.info("[Scheduler] 新员工检查完成 (TODO: 实现)")
def start_scheduler():
"""启动定时任务调度器"""
if not scheduler.running:
setup_scheduled_tasks()
scheduler.start()
logger.info("定时任务调度器已启动")
def stop_scheduler():
"""停止定时任务调度器"""
if scheduler.running:
scheduler.shutdown()
logger.info("定时任务调度器已停止")
@@ -0,0 +1,193 @@
# =============================================================================
# 企微IT智能服务台 — Token异常检测定时任务
# =============================================================================
# 说明:定时检测Token异常使用行为:
# 1. 同一Token在多个不同IP使用(可能凭证泄露)
# 2. 检测时间窗口:1小时内
# 3. 告警阈值:>=3个不同IP
# 运行频率:每5分钟执行一次
# =============================================================================
import logging
from datetime import datetime
import redis.asyncio as aioredis
from app.config import settings
from app.services.token_service import TokenService
logger = logging.getLogger(__name__)
# 异常检测配置
TOKEN_ANOMALY_IP_THRESHOLD = 3 # 同一Token使用不同IP数量阈值
TOKEN_ANOMALY_TIME_WINDOW = 3600 # 检测时间窗口(秒)
# Redis Key前缀
TOKEN_IP_PREFIX = "token_ip:"
async def detect_token_ip_anomaly():
"""检测Token异常使用行为。
检测逻辑:
1. 扫描所有 token_ip:* 的Key
2. 解析IP列表,统计不同IP数量
3. 超过阈值则触发告警
"""
try:
# 创建Redis客户端
redis_client = settings.create_redis_client()
token_service = TokenService(redis_client)
# 扫描所有Token IP记录
cursor = 0
anomaly_count = 0
anomalies = []
while True:
cursor, keys = await redis_client.scan(
cursor=cursor,
match=f"{TOKEN_IP_PREFIX}*",
count=100
)
for key in keys:
# 解析key获取token_hash
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
token_hash = key_str.replace(f"{TOKEN_IP_PREFIX}", "")
# 获取IP记录
data = await redis_client.get(key)
if not data:
continue
data_str = data.decode("utf-8") if isinstance(data, bytes) else data
# 解析IP列表
ips = set()
for entry in data_str.split(","):
if "@" in entry:
ip, _ = entry.rsplit("@", 1)
ips.add(ip)
# 检测异常
if len(ips) >= TOKEN_ANOMALY_IP_THRESHOLD:
anomaly_count += 1
anomalies.append({
"token_hash": token_hash,
"ip_count": len(ips),
"ips": list(ips),
"timestamp": datetime.now().isoformat()
})
logger.warning(
f"检测到Token异常使用: token_hash={token_hash}, "
f"ip_count={len(ips)}, ips={list(ips)}"
)
if cursor == 0:
break
# 发送告警
if anomalies:
await _send_anomaly_alert(anomalies)
logger.info(f"Token异常检测完成: 检测到 {anomaly_count} 个异常")
await redis_client.aclose()
except Exception as e:
logger.error(f"Token异常检测任务执行失败: {e}", exc_info=True)
async def _send_anomaly_alert(anomalies: list):
"""发送Token异常告警。
通过企微机器人发送告警消息。
Args:
anomalies: 异常列表
"""
if not anomalies:
return
# 检查是否配置了webhook
webhook = getattr(settings, "content_audit_webhook", None)
if not webhook:
logger.warning("未配置 content_audit_webhook,跳过告警")
return
try:
import httpx
# 构建告警消息
lines = ["🚨 **Token异常使用告警**\n"]
lines.append(f"**检测时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"**异常数量**: {len(anomalies)}")
lines.append("\n**异常详情:**")
for i, a in enumerate(anomalies[:5], 1): # 最多显示5条
ips_str = ", ".join(a["ips"][:3])
if len(a["ips"]) > 3:
ips_str += f" ... (+{len(a['ips']) - 3} more)"
lines.append(f"{i}. Token: `{a['token_hash'][:8]}...`")
lines.append(f" IP数量: {a['ip_count']}, IPs: {ips_str}")
if len(anomalies) > 5:
lines.append(f"\n... 还有 {len(anomalies) - 5} 条异常")
lines.append("\n⚠️ **建议**: 立即检查是否为凭证泄露,必要时禁用相关账户")
content = "\n".join(lines)
# 发送企微机器人消息
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
webhook,
json={
"msgtype": "markdown",
"markdown": {
"content": content
}
}
)
logger.info(f"Token异常告警已发送: {len(anomalies)}")
except Exception as e:
logger.error(f"发送Token异常告警失败: {e}")
async def test_token_anomaly_detection():
"""测试用:模拟Token多IP使用场景。
创建测试数据,验证检测逻辑。
"""
import hashlib
redis_client = settings.create_redis_client()
token_service = TokenService(redis_client)
# 生成测试token
test_token = "test_token_anomaly_12345"
token_hash = hashlib.sha256(test_token.encode()).hexdigest()[:16]
# 模拟同一token使用3个不同IP
key = f"{TOKEN_IP_PREFIX}{token_hash}"
test_data = (
"192.168.1.100@2026-07-14T10:00:00,"
"192.168.1.101@2026-07-14T10:05:00,"
"192.168.1.102@2026-07-14T10:10:00"
)
await redis_client.setex(key, 3600, test_data)
logger.info(f"测试数据已创建: key={key}, data={test_data}")
await redis_client.aclose()
logger.info("测试数据创建完成,请运行检测任务验证")
if __name__ == "__main__":
import asyncio
# 直接运行时执行测试
asyncio.run(test_token_anomaly_detection())