diff --git a/backend/app/api/admin/welcome.py b/backend/app/api/admin/welcome.py new file mode 100644 index 0000000..608764d --- /dev/null +++ b/backend/app/api/admin/welcome.py @@ -0,0 +1,387 @@ +# ============================================================================= +# 企微IT智能服务台 — 欢迎与引导配置 API +# ============================================================================= +# 说明:管理员工端H5的欢迎页、引导视频、互动教学、主题模板配置 +# GET /api/admin/welcome-config - 获取全部配置 +# PUT /api/admin/welcome-config - 更新配置 +# POST /api/admin/welcome-video - 上传视频 +# DELETE /api/admin/welcome-video - 删除视频 +# ============================================================================= + +import json +import logging +import os +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.agents import get_current_agent +from app.database import get_db +from app.models.agent import Agent +from app.models.system_config import SystemConfig +from app.utils.response import AppException, success_response + +logger = logging.getLogger(__name__) + +# 创建路由器 +router = APIRouter(prefix="/admin/welcome-config", tags=["欢迎与引导配置"]) + +# -------------------------------------------------------------------------- +# 请求/响应模型 +# -------------------------------------------------------------------------- + +# 欢迎页配置 +class WelcomePageConfig(BaseModel): + enabled: bool = False + title: str = "欢迎使用IT服务台" + content: str = "在这里您可以提交IT问题申请,获取快速支持" + background_color: str = "#07C160" + + +# 引导视频配置 +class WelcomeVideoConfig(BaseModel): + url: str + filename: str + uploaded_at: str + + +# 教程步骤 +class TutorialStep(BaseModel): + id: str + title: str + description: str + + +# 互动教学配置 +class TutorialConfig(BaseModel): + enabled: bool = False + steps: list[TutorialStep] = [] + + +# 主题配置 +class ThemeConfig(BaseModel): + theme: str = "default" # default/blue/orange/red + + +# 完整配置 +class WelcomeConfigData(BaseModel): + welcome_page: WelcomePageConfig = WelcomePageConfig() + welcome_video: Optional[WelcomeVideoConfig] = None + tutorial: TutorialConfig = TutorialConfig() + theme: ThemeConfig = ThemeConfig() + + +# 更新配置请求 +class UpdateWelcomeConfigRequest(BaseModel): + welcome_page: Optional[WelcomePageConfig] = None + welcome_video: Optional[WelcomeVideoConfig] = None + tutorial: Optional[TutorialConfig] = None + theme: Optional[ThemeConfig] = None + + +# -------------------------------------------------------------------------- +# 视频存储配置 +# -------------------------------------------------------------------------- +VIDEO_UPLOAD_DIR = Path(os.getenv("VIDEO_UPLOAD_DIR", "./uploads/videos")) +VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +MAX_VIDEO_SIZE = int(os.getenv("MAX_VIDEO_SIZE", str(50 * 1024 * 1024))) # 50MB + + +# -------------------------------------------------------------------------- +# 权限校验依赖 +# -------------------------------------------------------------------------- +async def require_admin( + agent: Agent = Depends(get_current_agent), +) -> Agent: + """管理后台权限校验:仅 role='admin' 可访问。""" + if agent.role != "admin": + raise AppException(1004, "无管理权限") + return agent + + +# -------------------------------------------------------------------------- +# 辅助函数 +# -------------------------------------------------------------------------- + +async def _get_welcome_config(db: AsyncSession) -> WelcomeConfigData: + """从数据库获取欢迎配置。""" + stmt = select(SystemConfig).where( + SystemConfig.config_key == "welcome_config" + ) + result = await db.execute(stmt) + config_row = result.scalars().first() + + if config_row: + try: + data = json.loads(config_row.config_value) + return WelcomeConfigData(**data) + except (json.JSONDecodeError, TypeError): + pass + + # 返回默认配置 + return WelcomeConfigData() + + +async def _save_welcome_config( + db: AsyncSession, + config_data: WelcomeConfigData, +) -> None: + """保存欢迎配置到数据库。""" + stmt = select(SystemConfig).where( + SystemConfig.config_key == "welcome_config" + ) + result = await db.execute(stmt) + config_row = result.scalars().first() + + config_value = config_data.model_dump_json() + + if config_row: + config_row.config_value = config_value + config_row.updated_at = datetime.now() + else: + new_config = SystemConfig( + id=str(uuid.uuid4()), + config_key="welcome_config", + config_value=config_value, + description="员工端H5欢迎与引导配置", + ) + db.add(new_config) + + await db.flush() + + +# -------------------------------------------------------------------------- +# API 端点 +# -------------------------------------------------------------------------- + +# ---------- GET /api/v1/admin/welcome-config ---------- +@router.get("") +async def get_welcome_config( + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> dict: + """获取欢迎与引导配置。 + + Args: + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含全部配置 + """ + config = await _get_welcome_config(db) + return success_response(data=config.model_dump()) + + +# ---------- PUT /api/v1/admin/welcome-config ---------- +@router.put("") +async def update_welcome_config( + body: UpdateWelcomeConfigRequest, + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> dict: + """更新欢迎与引导配置。 + + Args: + body: 更新请求体 + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含更新后的配置 + """ + # 获取当前配置 + current_config = await _get_welcome_config(db) + + # 合并更新 + if body.welcome_page is not None: + current_config.welcome_page = body.welcome_page + if body.welcome_video is not None: + current_config.welcome_video = body.welcome_video + if body.tutorial is not None: + current_config.tutorial = body.tutorial + if body.theme is not None: + current_config.theme = body.theme + + # 保存配置 + await _save_welcome_config(db, current_config) + await db.commit() + + logger.info(f"管理员更新欢迎配置: admin={admin.user_id}") + return success_response(data=current_config.model_dump()) + + +# ---------- POST /api/v1/admin/welcome-video ---------- +@router.post("/video") +async def upload_welcome_video( + file: UploadFile = File(..., description="引导视频文件"), + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> dict: + """上传引导视频。 + + Args: + file: 视频文件 + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含视频信息 + """ + # 校验文件类型 + allowed_extensions = {"mp4"} + ext = file.filename.split(".")[-1].lower() if file.filename else "" + + if ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"不支持的视频格式: .{ext},仅支持 .mp4", + ) + + # 校验文件大小 + content = await file.read() + file_size = len(content) + + if file_size > MAX_VIDEO_SIZE: + raise HTTPException( + status_code=400, + detail=f"视频大小 {file_size / 1024 / 1024:.1f}MB 超过限制(50MB)", + ) + + # 生成存储路径(使用与普通文件上传相同的目录结构) + now = datetime.now() + file_id = uuid.uuid4().hex[:12] + filename = f"{file_id}.{ext}" + # 按日期分目录:uploads/videos/YYYY/MM/DD/ + video_dir = VIDEO_UPLOAD_DIR / f"{now.year}" / f"{now.month:02d}" / f"{now.day:02d}" + video_dir.mkdir(parents=True, exist_ok=True) + storage_path = video_dir / filename + + # 保存文件 + try: + with open(storage_path, "wb") as f: + f.write(content) + except OSError as e: + logger.error(f"视频保存失败: {e}") + raise HTTPException(status_code=500, detail="视频保存失败,请重试") + + # 构建访问 URL(与普通文件上传格式一致) + video_url = f"/api/media/videos/{now.year}/{now.month:02d}/{now.day:02d}/{filename}" + + # 更新数据库配置 + config = await _get_welcome_config(db) + config.welcome_video = WelcomeVideoConfig( + url=video_url, + filename=file.filename or filename, + uploaded_at=now.isoformat(), + ) + await _save_welcome_config(db, config) + await db.commit() + + logger.info(f"管理员上传引导视频: {file.filename}, admin={admin.user_id}") + + return success_response(data={ + "url": video_url, + "filename": file.filename or filename, + "uploaded_at": now.isoformat(), + }) + + +# ---------- DELETE /api/admin/welcome-video ---------- +@router.delete("/video") +async def delete_welcome_video( + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> dict: + """删除引导视频。 + + Args: + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + Dict: 统一响应格式 + """ + # 获取当前配置 + config = await _get_welcome_config(db) + + if not config.welcome_video: + raise AppException(4001, "暂无引导视频") + + # 删除文件 + video_url = config.welcome_video.url + if video_url and video_url.startswith("/api/media/videos/"): + # 解析URL: /api/media/videos/YYYY/MM/DD/filename + parts = video_url.split("/") + if len(parts) >= 8: + year = parts[-4] + month = parts[-3] + day = parts[-2] + filename = parts[-1] + file_path = VIDEO_UPLOAD_DIR / year / month / day / filename + try: + if file_path.exists(): + file_path.unlink() + except OSError as e: + logger.warning(f"删除视频文件失败: {e}") + + # 清除配置 + config.welcome_video = None + await _save_welcome_config(db, config) + await db.commit() + + logger.info(f"管理员删除引导视频: admin={admin.user_id}") + return success_response(data=None, message="视频已删除") + + +# ---------- GET /api/media/videos/{year}/{month}/{day}/{filename} ---------- +@router.get("/media/videos/{year}/{month}/{day}/{filename}") +async def serve_welcome_video( + year: str, + month: str, + day: str, + filename: str, +): + """提供引导视频的静态访问。 + + Args: + year: 年份 + month: 月份 + day: 日期 + filename: 文件名 + + Returns: + FileResponse: 视频文件响应 + """ + file_path = VIDEO_UPLOAD_DIR / year / month / day / filename + + # 安全检查:防止路径遍历攻击 + try: + resolved = file_path.resolve() + upload_root = VIDEO_UPLOAD_DIR.resolve() + if not str(resolved).startswith(str(upload_root)): + raise HTTPException(status_code=403, detail="禁止访问") + except (ValueError, OSError): + raise HTTPException(status_code=403, detail="禁止访问") + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="视频文件不存在") + + # 根据扩展名设置 Content-Type + content_type = "video/mp4" + return FileResponse(file_path, media_type=content_type) + + +# -------------------------------------------------------------------------- +# 注册路由到主路由 +# -------------------------------------------------------------------------- +def register_welcome_routes(main_router: APIRouter) -> None: + """将欢迎配置路由注册到主路由。""" + main_router.include_router(router) diff --git a/backend/app/api/admin_api.py b/backend/app/api/admin_api.py index a5c9d4c..b70fab3 100644 --- a/backend/app/api/admin_api.py +++ b/backend/app/api/admin_api.py @@ -19,12 +19,14 @@ from datetime import datetime from typing import Optional from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Form, Query, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from app.api.agents import get_current_agent from app.database import get_db from app.models.agent import Agent +from app.models.device_inventory import DeviceInventory +from app.services.device_import_service import DeviceImportService from app.schemas.admin import ( AgentCreateRequest, AgentUpdateRequest, @@ -771,7 +773,7 @@ async def test_lianruan_connection( Dict: 包含 success(bool) 和 message(str) """ from app.integrations.lianruan.config import get_lianruan_client - from app.integrations.lianruan.exceptions import LianruanConfigError + from app.integrations.lianruan.exceptions import LianruanConfigError, LianruanAuthError, LianruanConnectionError try: client = await get_lianruan_client(db) @@ -782,6 +784,16 @@ async def test_lianruan_connection( "success": False, "message": e.message, }) + except LianruanAuthError as e: + return success_response(data={ + "success": False, + "message": f"认证失败: {e.message}", + }) + except LianruanConnectionError as e: + return success_response(data={ + "success": False, + "message": f"连接失败: {e.message}", + }) # ---------- GET /api/admin/integrations/lianruan/terminals ---------- @@ -843,6 +855,7 @@ async def list_audit_conversations( keyword: Optional[str] = Query(None, description="按员工姓名/消息摘要搜索"), date_from: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"), date_to: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), + is_archived: Optional[bool] = Query(None, description="按是否归档筛选"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=100, description="每页条数"), admin: Agent = Depends(require_admin), @@ -851,7 +864,8 @@ async def list_audit_conversations( """获取会话审计列表(支持分页+多条件筛选)。""" result = await admin_service.list_audit_conversations( db, status=status, agent_id=agent_id, keyword=keyword, - date_from=date_from, date_to=date_to, page=page, page_size=page_size, + date_from=date_from, date_to=date_to, is_archived=is_archived, + page=page, page_size=page_size, ) return success_response(data=result) @@ -1108,3 +1122,83 @@ async def revoke_user_token( "revoked_count": revoked_count, "message": f"已撤销 {revoked_count} 个 Token" if revoked_count > 0 else "未找到该用户的有效 Token", }) + + +# ========================================================================== +# 15. 设备清单导入(从联软/火绒导出文件) +# ========================================================================== + +# ---------- POST /api/admin/device-inventory/import ---------- +@router.post("/device-inventory/import") +async def import_device_inventory( + source: str = Form(..., description="数据来源: huorong 或 lianruan"), + file: UploadFile = Form(..., description="Excel 文件"), + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """从 Excel 文件导入设备清单。 + + 支持从联软/火绒导出的 Excel 文件中批量导入设备信息。 + 导入后会根据员工账号自动匹配员工信息。 + """ + import pandas as pd + from io import BytesIO + + # 读取文件内容 + content = await file.read() + df = pd.read_excel(BytesIO(content)) + + # 转换为字典列表 + records = df.to_dict(orient="records") + + # 导入 + import_service = DeviceImportService(db) + if source == "huorong": + count = await import_service.import_huorong(records) + elif source == "lianruan": + count = await import_service.import_lianruan(records) + else: + raise AppException(4001, "无效的数据来源,仅支持 huorong 或 lianruan") + + return success_response(data={"imported": count, "source": source}) + + +# ---------- GET /api/admin/device-inventory ---------- +@router.get("/device-inventory") +async def list_device_inventory( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(50, ge=1, le=200, description="每页条数"), + source: Optional[str] = Query(None, description="数据来源筛选"), + employee_account: Optional[str] = Query(None, description="员工账号筛选"), + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """查询设备清单列表。""" + from sqlalchemy import select, func + + query = select(DeviceInventory) + count_query = select(func.count(DeviceInventory.id)) + + if source: + query = query.where(DeviceInventory.source == source) + count_query = count_query.where(DeviceInventory.source == source) + + if employee_account: + query = query.where(DeviceInventory.employee_account == employee_account) + count_query = count_query.where(DeviceInventory.employee_account == employee_account) + + # 总数 + total = await db.scalar(count_query) + + # 分页 + offset = (page - 1) * page_size + query = query.offset(offset).limit(page_size).order_by(DeviceInventory.updated_at.desc()) + result = await db.execute(query) + items = result.scalars().all() + + return success_response(data={ + "items": [d.model_dump() for d in items], + "total": total, + "page": page, + "page_size": page_size, + }) diff --git a/backend/app/api/agent_directory.py b/backend/app/api/agent_directory.py index be1560d..bd505a8 100644 --- a/backend/app/api/agent_directory.py +++ b/backend/app/api/agent_directory.py @@ -12,7 +12,6 @@ # ============================================================================= import logging -from collections import OrderedDict from typing import Any, Dict, List, Optional import redis.asyncio as aioredis @@ -23,7 +22,11 @@ from app.api.agents import get_current_agent from app.database import get_db from app.dependencies import dep_redis from app.models.agent import Agent -from app.services.employee_directory import get_org_directory +from app.services.employee_directory import ( + count_tree_employees, + get_org_directory, + get_org_tree_cached, +) from app.utils.response import AppException, success_response logger = logging.getLogger(__name__) @@ -63,7 +66,7 @@ async def search_employees( return success_response(data=[]) try: - # 获取组织目录(含 10 分钟 Redis 缓存 + 本地降级,无需修改 employee_directory.py) + # 获取组织目录(含 30 分钟 Redis 缓存 + 本地降级) directory, _ = await get_org_directory(db, redis) kw_lower = kw.lower() @@ -107,24 +110,34 @@ async def get_org_tree( ): """获取组织架构树(部门层级 + 每个部门下的员工列表)。 - 复用 get_org_directory() 获取员工列表(已含 department 字段), - 在服务端按 department 分组构建树结构。排除当前登录坐席自己。 + 利用企微 department/list 返回的 parentid 字段构建真正的层级树, + 不再按部门名扁平分组。部门ID加 ``dept_`` 前缀作为唯一 key, + 避免同名部门合并。员工可以出现在其所属的所有部门下。 - 树结构示例: + 树结构示例(多层级): [ { - "id": "研发一部", - "label": "研发一部", + "id": "dept_1", + "label": "公司", + "dept_id": 1, + "parentid": 0, "children": [ - {"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"} + { + "id": "dept_2", + "label": "研发一部", + "dept_id": 2, + "parentid": 1, + "children": [ + {"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"} + ] + } ] } ] - 规则: - - department 为空的员工归到"未分配部门"分组 - - 企微返回多部门(逗号分隔)时,取第一个作为主部门 - - 部门按名称排序,部门内员工按姓名排序 + 性能优化: + - 树构建结果独立缓存(key: wecom:org_tree:agent,TTL 30 分钟) + - 缓存中包含所有员工,读取后过滤掉当前登录坐席自己 Args: current_agent: 当前坐席 @@ -135,58 +148,11 @@ async def get_org_tree( Dict: 统一响应格式,data 为树节点列表 """ try: - # 获取组织目录(含 Redis 缓存 + 本地降级) - directory, _ = await get_org_directory(db, redis) + # 获取组织架构树(含独立缓存 + 排除当前坐席) + tree = await get_org_tree_cached(db, redis, "agent", current_agent.user_id) - # 按部门分组(OrderedDict 保持稳定插入顺序,后续再排序) - dept_groups: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict() - - for emp in directory: - # 排除当前坐席自己 - if emp.get("employee_id") == current_agent.user_id: - continue - - # 取部门名:为空则归"未分配部门";多部门(逗号分隔)取第一个 - dept = (emp.get("department") or "").strip() - if not dept: - dept = "未分配部门" - else: - dept = dept.split(",")[0].strip() - if not dept: - dept = "未分配部门" - - if dept not in dept_groups: - dept_groups[dept] = [] - dept_groups[dept].append(emp) - - # 构建树节点(部门按名称排序,员工按姓名排序) - tree: List[Dict[str, Any]] = [] - for dept_name in sorted(dept_groups.keys()): - employees = dept_groups[dept_name] - if not employees: - # 跳过空部门(理论上不会出现,防御性编程) - continue - - # 部门内员工按姓名排序 - employees.sort(key=lambda e: e.get("name", "")) - - tree.append({ - "id": dept_name, - "label": dept_name, - "children": [ - { - "id": emp.get("employee_id", ""), - "label": emp.get("name", ""), - # isLeaf=true 标记为叶子节点(员工),前端 el-tree 据此区分部门/员工 - "isLeaf": True, - "department": dept_name, - } - for emp in employees - ], - }) - - total_employees = sum(len(node["children"]) for node in tree) - logger.info(f"组织架构树: {len(tree)} 个部门, 共 {total_employees} 人") + total_employees = count_tree_employees(tree) + logger.info(f"组织架构树: {len(tree)} 个顶层节点, 共 {total_employees} 人") return success_response(data=tree) except AppException: diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 2078879..10bf667 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -30,7 +30,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, dep_wecom_service +from app.dependencies import get_current_user, require_role, dep_wecom_service, UserInfo from app.models.agent import Agent from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate from app.services.wecom_service import WecomService @@ -425,6 +425,7 @@ async def update_agent_status( async def list_agents( status: Optional[str] = Query(None, description="按状态过滤: online/busy/offline"), db: AsyncSession = Depends(get_db), + current_user: UserInfo = Depends(get_current_user), ): """获取坐席列表。 diff --git a/backend/app/api/approval.py b/backend/app/api/approval.py index 40ccd21..ae43865 100644 --- a/backend/app/api/approval.py +++ b/backend/app/api/approval.py @@ -54,36 +54,39 @@ APPROVAL_TEMPLATES: dict[str, dict] = { "location": "企微审批", }, # --- 设备申请 --- + # IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效) "asset_receive": { "id": "asset_receive", - "name": "资产领用登记", + "name": "IT资产领用申请", "type": "jump", "keywords": ["资产领用", "领用登记", "设备领用"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt31AbSHwN9MuaFhYXt4Qwsx6ZLCftAFh6X1w&sp_id=&from=template_list", - "location": "企微审批", + "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", + "location": "运维平台", }, + # IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效) "asset_borrow": { "id": "asset_borrow", - "name": "资产借用申请", + "name": "IT资产借用申请", "type": "jump", "keywords": ["资产借用", "借用", "借用设备"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3TmACnFs8oqgYcasxVh4BfSMGNX7p9sb6ydBX77mK&sp_id=&from=template_list", - "location": "企微审批", + "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", + "location": "运维平台", }, + # IT资产升级 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效) "asset_upgrade": { "id": "asset_upgrade", "name": "IT资产升级申请", "type": "jump", "keywords": ["资产升级", "设备升级", "升级"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS&sp_id=&from=template_list", - "location": "企微审批", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", + "location": "运维平台", }, "it_device_repair": { "id": "it_device_repair", "name": "IT设备升级与硬件维修", "type": "jump", "keywords": ["设备升级", "硬件维修", "设备维修"], - "url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", "location": "运维平台", }, # --- 账号权限申请 --- @@ -100,17 +103,17 @@ APPROVAL_TEMPLATES: dict[str, dict] = { "name": "员工零信任(原VPN)账号", "type": "jump", "keywords": ["VPN", "vpn", "零信任", "VPN账号"], - "url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", "location": "运维平台", }, - # --- 软件服务申请 --- + # --- 软件服务申请 --- 改用ITSM工单系统(企微审批模板 3TmACf8D... 已失效) "software_service": { "id": "software_service", "name": "商业软件服务申请", "type": "jump", "keywords": ["软件", "商业软件", "软件服务"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3TmACf8DsJy5yr7aymanLskywC4EDhFLuz1KuBBQK&sp_id=&from=template_list", - "location": "企微审批", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7", + "location": "运维平台", }, # --- 资产处置申请 --- "asset_repair": { @@ -170,7 +173,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = { "name": "终端设备网络准入申请", "type": "jump", "keywords": ["网络准入", "终端准入", "准入申请"], - "url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7", "location": "运维平台", }, # --- 活动与会议技术支持 --- @@ -179,7 +182,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = { "name": "活动与会议技术支持", "type": "jump", "keywords": ["活动支持", "会议支持", "技术支持", "活动技术"], - "url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81", "location": "运维平台", }, # --- 员工IT支持与故障报修 --- @@ -188,7 +191,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = { "name": "员工IT支持与故障报修", "type": "jump", "keywords": ["故障报修", "IT支持", "技术支持", "报修"], - "url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE", "location": "运维平台", }, # --- 公共邮箱账号申请 --- @@ -197,7 +200,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = { "name": "公共邮箱账号申请", "type": "jump", "keywords": ["公共邮箱", "公共账号", "共享邮箱"], - "url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", + "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", "location": "运维平台", }, } diff --git a/backend/app/api/approval.py.bak_bridge_1784261164 b/backend/app/api/approval.py.bak_bridge_1784261164 new file mode 100644 index 0000000..a888d6c --- /dev/null +++ b/backend/app/api/approval.py.bak_bridge_1784261164 @@ -0,0 +1,1127 @@ +# ============================================================================= +# IT智能服务台 — 审批流程 API +# ============================================================================= +# 说明:提供审批模板管理和API提交功能 +# - 模板详情获取 +# - API提交审批申请 +# - 审批状态回调处理 +# ============================================================================= + +import asyncio +import json +import logging +import os +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +import redis.asyncio as aioredis + +from app.config import settings +from app.services.asset_service import AssetService +from app.services.wecom_service import WecomService +from app.utils.token_manager import TokenManager +from app.utils.response import success_response + +logger = logging.getLogger(__name__) +router = APIRouter() + +# IT资产升级申请模板ID(回调时用于识别审批类型,触发年限核查推送) +ASSET_UPGRADE_TEMPLATE_ID = "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS" + +# Redis客户端(依赖注入) +async def get_redis() -> aioredis.Redis: + """获取Redis客户端依赖""" + from app.main import redis_client + return redis_client + + +# ============================================================================= +# 审批模板配置(静态硬编码 — 18个审批流程,12种审批类型) +# ============================================================================= +# 数据来源:docs/02-产品需求/approval_templates.json +# 所有模板 type 统一为 "jump"(直接URL跳转) + +APPROVAL_TEMPLATES: dict[str, dict] = { + # --- 办公用品申请 --- + "office_supplies": { + "id": "office_supplies", + "name": "办公用品超额领用审批", + "type": "jump", + "keywords": ["办公用品", "超额", "领用"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WN6zRucbjycdnR94gBvkSVuXRamX7pKW4PrmNFh&sp_id=&from=template_list", + "location": "企微审批", + }, + # --- 设备申请 --- + # IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效) + "asset_receive": { + "id": "asset_receive", + "name": "IT资产领用申请", + "type": "jump", + "keywords": ["资产领用", "领用登记", "设备领用"], + "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", + "location": "运维平台", + }, + # IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效) + "asset_borrow": { + "id": "asset_borrow", + "name": "IT资产借用申请", + "type": "jump", + "keywords": ["资产借用", "借用", "借用设备"], + "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", + "location": "运维平台", + }, + # IT资产升级 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效) + "asset_upgrade": { + "id": "asset_upgrade", + "name": "IT资产升级申请", + "type": "jump", + "keywords": ["资产升级", "设备升级", "升级"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", + "location": "运维平台", + }, + "it_device_repair": { + "id": "it_device_repair", + "name": "IT设备升级与硬件维修", + "type": "jump", + "keywords": ["设备升级", "硬件维修", "设备维修"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", + "location": "运维平台", + }, + # --- 账号权限申请 --- + "wecom_external": { + "id": "wecom_external", + "name": "企微外联权限申请", + "type": "jump", + "keywords": ["外联", "企微外联", "外部联系人"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WrCbZd214XrDMZJiHDho7ZQHWX7gsabb7x2fF72&sp_id=&from=template_list", + "location": "企微审批", + }, + "zero_trust_vpn": { + "id": "zero_trust_vpn", + "name": "员工零信任(原VPN)账号", + "type": "jump", + "keywords": ["VPN", "vpn", "零信任", "VPN账号"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", + "location": "运维平台", + }, + # --- 软件服务申请 --- 改用ITSM工单系统(企微审批模板 3TmACf8D... 已失效) + "software_service": { + "id": "software_service", + "name": "商业软件服务申请", + "type": "jump", + "keywords": ["软件", "商业软件", "软件服务"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7", + "location": "运维平台", + }, + # --- 资产处置申请 --- + "asset_repair": { + "id": "asset_repair", + "name": "IT资产外修申请", + "type": "jump", + "keywords": ["外修", "资产外修", "设备维修"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X&sp_id=&from=template_list", + "location": "企微审批", + }, + "asset_scrap": { + "id": "asset_scrap", + "name": "IT资产报废申请", + "type": "jump", + "keywords": ["报废", "资产报废", "设备报废"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WroCDfWuHKyujQjatjm3AjNv67imXk5C6WNooFkb&sp_id=&from=template_list", + "location": "企微审批", + }, + "asset_return": { + "id": "asset_return", + "name": "资产退还登记", + "type": "jump", + "keywords": ["退还", "资产退还", "退还设备"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt33AZ52a7n9BBWDh6PmsDnpM5B6w8geqqqoHz&sp_id=&from=template_list", + "location": "企微审批", + }, + # --- 会议室故障报修 --- + "meeting_room_repair": { + "id": "meeting_room_repair", + "name": "会议室故障报修", + "type": "jump", + "keywords": ["会议室", "故障报修", "报修"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4ZXJMtjQJiPXo6N5vNMK26uPRT3KTi9VvkH2NScg&sp_id=&from=template_list", + "location": "企微审批", + }, + # --- 企业应用管理 --- + "app_management": { + "id": "app_management", + "name": "企业应用管理", + "type": "jump", + "keywords": ["企业应用", "应用管理", "应用开通"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WLJnRg2Se1fwizQtNvFtcYMgci1mhRJZhMw2FFKb&sp_id=&from=template_list", + "location": "企微审批", + }, + # --- 资产变更确认 --- + "asset_change": { + "id": "asset_change", + "name": "资产变更确认", + "type": "jump", + "keywords": ["资产变更", "变更确认", "变更"], + "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4cA2owjRcXRRPQ46otZvUHoWNEKL5t25tHHfeePip&sp_id=&from=template_list", + "location": "企微审批", + }, + # --- 终端设备网络准入 --- + "network_access": { + "id": "network_access", + "name": "终端设备网络准入申请", + "type": "jump", + "keywords": ["网络准入", "终端准入", "准入申请"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7", + "location": "运维平台", + }, + # --- 活动与会议技术支持 --- + "event_support": { + "id": "event_support", + "name": "活动与会议技术支持", + "type": "jump", + "keywords": ["活动支持", "会议支持", "技术支持", "活动技术"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81", + "location": "运维平台", + }, + # --- 员工IT支持与故障报修 --- + "it_support_repair": { + "id": "it_support_repair", + "name": "员工IT支持与故障报修", + "type": "jump", + "keywords": ["故障报修", "IT支持", "技术支持", "报修"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE", + "location": "运维平台", + }, + # --- 公共邮箱账号申请 --- + "public_email": { + "id": "public_email", + "name": "公共邮箱账号申请", + "type": "jump", + "keywords": ["公共邮箱", "公共账号", "共享邮箱"], + "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", + "location": "运维平台", + }, +} + + +# ============================================================================= +# 审批意图识别 — 关键词预过滤 & 降级兜底 +# ============================================================================= +# 说明:覆盖 12 种审批类型、18 个审批流程的关键词,用于快速过滤非审批消息, +# 避免 Dify 被每条消息调用。当 Dify 不可用时降级为关键词匹配。 + +# 审批关键词预过滤列表(v2.0 收窄版 — 2026-07-13) +# ------------------------------------------------------------ +# 设计原则:只保留「强意图词」和「复合专有词」,移除高频泛化词。 +# +# 改造前 ~40 词(含"设备""电脑""邮箱""权限""软件""报修"等), +# 几乎覆盖所有 IT 消息 → 预过滤命中率 ~60% → 大量无效 Dify 调用。 +# +# 改造后策略: +# - 强意图动词:用户明确表达"申请/提交"意愿时才触发 +# - 复合专有词:仅在审批场景出现,不会在普通 IT 咨询中出现 +# - 移除的泛化词(设备/电脑/邮箱/权限/软件/报修/变更/会议室等) +# 改由 Dify 意图识别内部判断,不作为预过滤触发条件 +# +# 预期效果:预过滤命中率从 ~60% 降至 ~15%,减少 75% 无效 Dify 调用 +APPROVAL_PREFILTER_KEYWORDS: list[str] = [ + # === 强意图动词/短语(用户明确表达申请意愿)=== + "申请", "审批", "提交", "表单", "走流程", + "帮我申请", "我要申请", "需要申请", "想申请", + # === 复合专有词(仅在审批场景出现,日常 IT 咨询不会用)=== + "资产领用", "资产借用", "资产升级", "资产变更", "资产处置", + "网络准入", "终端准入", + "办公用品", "超额领用", + "企业应用管理", "应用开通", + "公共邮箱", "共享邮箱", "公共账号", + "故障报修", "IT支持", + "自备电脑", "电脑补贴", "BYOD", "byod", +] + +# 关键词到审批类型的映射(Dify 不可用时降级兜底用) +KEYWORD_TO_APPROVAL_TYPE: dict[str, str] = { + # 设备申请 + "设备": "设备申请", "电脑": "设备申请", "笔记本": "设备申请", + "显示器": "设备申请", "资产领用": "设备申请", "资产借用": "设备申请", "资产升级": "设备申请", + # 账号权限申请 + "VPN": "账号权限申请", "vpn": "账号权限申请", "账号": "账号权限申请", + "邮箱": "账号权限申请", "外联": "账号权限申请", "权限": "账号权限申请", + # 软件服务申请 + "软件": "软件服务申请", "业务系统": "软件服务申请", + # 资产处置申请 + "外修": "资产处置申请", "报废": "资产处置申请", "退还": "资产处置申请", + # 办公用品申请 + "办公用品": "办公用品申请", "超额": "办公用品申请", + # 会议室故障报修 + "会议室": "会议室故障报修", "故障报修": "会议室故障报修", "报修": "会议室故障报修", + # 企业应用管理 + "企业应用": "企业应用管理", "应用管理": "企业应用管理", "应用开通": "企业应用管理", + # 资产变更确认 + "资产变更": "资产变更确认", "变更确认": "资产变更确认", "变更": "资产变更确认", + # 终端设备网络准入 + "网络准入": "终端设备网络准入", "终端准入": "终端设备网络准入", "准入申请": "终端设备网络准入", + # 活动与会议技术支持 + "活动支持": "活动与会议技术支持", "会议支持": "活动与会议技术支持", "活动技术": "活动与会议技术支持", + # 员工IT支持与故障报修 + "IT支持": "员工IT支持与故障报修", + # 公共邮箱账号申请 + "公共邮箱": "公共邮箱账号申请", "公共账号": "公共邮箱账号申请", "共享邮箱": "公共邮箱账号申请", +} + + +# ============================================================================= +# Schema 定义 +# ============================================================================= + +class ApprovalTemplateResponse(BaseModel): + """审批模板响应""" + id: str + name: str + type: str + keywords: list[str] + + +class ApprovalJumpRequest(BaseModel): + """跳转审批请求""" + template_id: str + employee_id: Optional[str] = None + + +class ApprovalJumpResponse(BaseModel): + """跳转审批响应""" + url: str + template_name: str + + +class ApprovalContentItem(BaseModel): + """审批表单控件内容""" + control: str # 控件类型: Text, Textarea, Number, Money, Date, Selector, Contact, etc. + id: str # 控件ID + value: dict # 控件值 + + +class ApprovalSubmitRequest(BaseModel): + """API提交审批请求""" + template_id: str + employee_id: str # 申请人userid + contents: list[ApprovalContentItem] # 表单内容 + use_template_approver: int = 1 # 1-使用模板预设流程 + + +class ApprovalSubmitResponse(BaseModel): + """API提交审批响应""" + sp_no: str # 审批单号 + template_name: str + + +class ApprovalCallbackRequest(BaseModel): + """审批回调请求(XML解析后的模型)""" + sp_no: str + sp_name: str + template_id: str + apply_time: int + applyer_userid: str + sp_status: int # 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付 + status_change_event: int # 1-提单 2-同意 3-驳回 4-转审 5-催办 6-撤销 8-通过后撤销 10-添加备注 + + +class ApprovalUrgeRequest(BaseModel): + """审批推送请求 — 手动触发资产年限核查+推送""" + sp_no: str + + +class ApprovalDetectIntentRequest(BaseModel): + """审批意图检测请求""" + text: str + employee_id: Optional[str] = None + + +class ApprovalDetectIntentResponse(BaseModel): + """审批意图检测响应 + + Attributes: + is_approval_request: 是否为审批请求(原字段,语义不变) + confidence: 置信度(0.0~1.0)(原字段,语义不变) + approval_type: 审批类型(原字段,语义不变) + source: 结果来源 — dify(Dify识别) / keyword_prefilter(关键词预过滤未命中) / fallback(降级兜底) + intent_type: 意图大类(新增)— approval/it_consult/non_it_routing/chitchat + business_category: 非IT业务类别(新增)— 行政/人力资源/财务/法务/行政-物业,仅 non_it_routing 时有值 + routing_confidence: 路由置信度(新增)— 0.0~1.0,≥0.7 触发名片推荐 + """ + is_approval_request: bool + confidence: float + approval_type: Optional[str] = None + source: str # "dify" | "keyword_prefilter" | "fallback" + # 以下为 v3 统一意图识别新增字段(向后兼容:原审批逻辑只读取前4个字段) + intent_type: str = "chitchat" + business_category: Optional[str] = None + routing_confidence: float = 0.0 + + +# ============================================================================= +# 企微API调用辅助函数 +# ============================================================================= + +async def get_approval_token(redis: aioredis.Redis) -> str: + """获取企微access_token(使用IT支持应用Secret,IP已在白名单中)。""" + manager = TokenManager(redis) + try: + return await manager.get_token() + finally: + await manager.close() + + +async def get_template_detail(access_token: str, template_id: str) -> dict: + """获取审批模板详情 + + 对应企微API: + POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail + + 返回模板内的控件构成及控件ID + """ + url = "https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail" + params = {"access_token": access_token} + + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: + response = await client.post(url, params=params, json={"template_id": template_id}) + result = response.json() + + if result.get("errcode") != 0: + logger.error(f"获取模板详情失败: {result.get('errmsg')}") + raise Exception(f"获取模板详情失败: {result.get('errmsg')}") + + return result + + +async def submit_approval_api( + access_token: str, + template_id: str, + creator_userid: str, + contents: list[dict], + use_template_approver: int = 1 +) -> dict: + """提交审批申请 + + 对应企微API: + POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent + + Args: + access_token: 审批应用access_token + template_id: 模板ID + creator_userid: 申请人userid + contents: 表单控件内容列表 + use_template_approver: 1-使用模板预设流程 + + Returns: + {"sp_no": "审批单号"} + """ + url = "https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent" + params = {"access_token": access_token} + + payload = { + "creator_userid": creator_userid, + "template_id": template_id, + "use_template_approver": use_template_approver, + "apply_data": { + "contents": contents + } + } + + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: + response = await client.post(url, params=params, json=payload) + result = response.json() + + if result.get("errcode") != 0: + logger.error(f"提交审批失败: {result.get('errmsg')}") + raise Exception(f"提交审批失败: {result.get('errmsg')}") + + return {"sp_no": result.get("sp_no")} + + +# ============================================================================= +# 资产升级审批推送 — 辅助函数 +# ============================================================================= + +async def get_approval_detail(access_token: str, sp_no: str) -> dict: + """获取审批申请详情。 + + 对应企微API: + POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail + + 返回审批单的表单数据、审批流程节点、当前审批人等信息。 + + Args: + access_token: 审批应用 access_token + sp_no: 审批单号 + + Returns: + dict: 企微API返回的完整审批详情 + + Raises: + Exception: API调用失败或返回错误 + """ + url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail" + params = {"access_token": access_token} + payload = {"sp_no": sp_no} + + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: + response = await client.post(url, params=params, json=payload) + result = response.json() + + if result.get("errcode") != 0: + logger.error(f"获取审批详情失败: sp_no={sp_no}, errmsg={result.get('errmsg')}") + raise Exception(f"获取审批详情失败: {result.get('errmsg')}") + + return result + + +async def get_approval_data( + access_token: str, + starttime: int, + endtime: int, + filters: Optional[list] = None, + cursor: int = 0, + size: int = 100, +) -> dict: + """获取审批申请数据列表。 + + 对应企微API: + POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata + + 按时间范围和过滤条件查询审批单列表,返回审批单号及基本信息。 + 支持 cursor 分页。 + + Args: + access_token: 审批应用 access_token + starttime: 查询开始时间(秒级 Unix 时间戳) + endtime: 查询结束时间(秒级 Unix 时间戳) + filters: 过滤条件列表,格式 [{"key": "template_id", "value": "xxx"}, ...] + cursor: 分页游标(从 0 开始) + size: 单页返回数量(最大 100) + + Returns: + dict: 企微API返回的完整响应,包含 data(审批列表)、total、next_cursor + + Raises: + Exception: API调用失败或返回错误 + """ + url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata" + params = {"access_token": access_token} + payload = { + "starttime": starttime, + "endtime": endtime, + "cursor": cursor, + "size": size, + } + if filters: + payload["filters"] = filters + + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: + response = await client.post(url, params=params, json=payload) + result = response.json() + + if result.get("errcode") != 0: + logger.error( + f"getapprovaldata 调用失败: errcode={result.get('errcode')}, " + f"errmsg={result.get('errmsg')}" + ) + raise Exception(f"获取审批数据列表失败: {result.get('errmsg')}") + + return result + + +def _extract_asset_code(approval_detail: dict) -> Optional[str]: + """从审批单表单数据中提取资产编号。 + + 遍历 apply_data.contents,查找标题包含"资产编号"/"固定资产编号"等关键字的控件, + 提取其值。支持 Text/Textarea(value.text)和 Selector(value.value)两种控件类型。 + + Args: + approval_detail: get_approval_detail 返回的完整审批详情 + + Returns: + str: 资产编号,提取失败返回 None + """ + try: + contents = ( + approval_detail + .get("info", {}) + .get("apply_data", {}) + .get("contents", []) + ) + # 资产编号可能的标题名 + asset_code_titles = {"资产编号", "固定资产编号", "资产编码", "设备编号"} + + for item in contents: + # 检查控件标题 + title_list = item.get("title", []) + for title_obj in title_list: + title_text = title_obj.get("text", "") + if title_text in asset_code_titles: + # 提取控件值 + value = item.get("value", {}) + # Text / Textarea 类型:value.text + text = value.get("text", "") + if text: + return text.strip() + # Selector 类型:value.value + sel_value = value.get("value", "") + if sel_value: + return sel_value.strip() + except Exception as e: + logger.error(f"提取资产编号失败: {e}") + return None + + +def _extract_current_approver(approval_detail: dict) -> Optional[str]: + """从审批单详情中提取当前审批人 userid。 + + 遍历 sp_record,查找 sp_status=1(审批中)的节点, + 返回其 details 中第一个 sp_status=1(待处理)的 approver.userid。 + + 注意:企微 API 实际返回的字段名: + - sp_record[].sp_status(非 status) + - sp_record[].approverattr(非 approverAttr) + - sp_record[].details[].approver.userid(非 sp_record[].approver[].userid) + + Args: + approval_detail: get_approval_detail 返回的完整审批详情 + + Returns: + str: 当前审批人 userid,提取失败返回 None + """ + try: + sp_record = approval_detail.get("info", {}).get("sp_record", []) + for record in sp_record: + # 企微 API 字段名为 sp_status(非 status),1=审批中 + if record.get("sp_status") != 1: + continue + + # 审批人信息在 details[].approver 中(非 record.approver) + details = record.get("details", []) + for detail in details: + # detail.sp_status=1 表示该审批人尚未处理 + if detail.get("sp_status") == 1: + approver = detail.get("approver", {}) + if isinstance(approver, dict): + userid = approver.get("userid", "") + if userid: + return userid + elif isinstance(approver, list) and len(approver) > 0: + userid = approver[0].get("userid", "") + if userid: + return userid + except Exception as e: + logger.error(f"提取当前审批人失败: {e}") + return None + + +def _build_urge_description(check_result: dict, applyer_userid: str) -> str: + """构建推送卡片的描述文本。 + + 根据资产核查结果拼接多行描述,包含资产编号、设备名称、启用日期、 + 已使用年限、核查结论、申请人等信息。 + + Args: + check_result: AssetService.check_device_age 返回的核查结果 + applyer_userid: 申请人 userid + + Returns: + str: 多行描述文本 + """ + lines = [] + + # 资产编号 + lines.append(f"资产编号:{check_result.get('asset_code', '未知')}") + + # 设备名称 + if check_result.get("asset_name"): + lines.append(f"设备名称:{check_result['asset_name']}") + + # 启用日期 + if check_result.get("start_date"): + lines.append(f"启用日期:{check_result['start_date']}") + + # 已使用年限 + if check_result.get("years_display"): + lines.append(f"已使用:{check_result['years_display']}") + + # 核查结论 + threshold = settings.asset_replacement_threshold_years + if check_result.get("found") and check_result.get("meets_threshold") is not None: + if check_result["meets_threshold"]: + lines.append(f"核查结论:✅ 已满{threshold}年,符合更换条件") + else: + lines.append(f"核查结论:❌ 未满{threshold}年,不符合更换条件") + elif not check_result.get("found"): + lines.append("核查结论:⚠️ 未找到资产记录,请人工核查") + else: + lines.append("核查结论:⚠️ 无法计算年限,请人工核查") + + # 申请人 + lines.append(f"申请人:{applyer_userid}") + + # 提示 + lines.append("请点击查看审批详情") + + return "\n".join(lines) + + +async def _do_asset_urge(sp_no: str, redis: aioredis.Redis) -> dict: + """执行资产年限核查 + 向审批人推送卡片消息的完整逻辑。 + + 流程: + 1. 获取审批应用 access_token + 2. 调用 oa/getapprovaldetail 获取审批单详情 + 3. 从表单数据提取资产编号 + 4. 从审批流程提取当前审批人 userid + 5. 用 AssetService 核查设备使用年限 + 6. 向当前审批人推送企微文本卡片消息 + + 所有步骤均有 try/except 保护,失败时记日志但不抛出异常。 + + Args: + sp_no: 审批单号 + redis: Redis 客户端(用于获取 access_token) + + Returns: + dict: 执行结果,包含 success/message 以及核查详情 + """ + try: + # 1. 获取审批应用 access_token + token = await get_approval_token(redis) + + # 2. 获取审批单详情 + detail = await get_approval_detail(token, sp_no) + + # 3. 提取资产编号 + asset_code = _extract_asset_code(detail) + if not asset_code: + logger.warning(f"审批单 {sp_no} 未能提取资产编号") + return {"success": False, "message": "未能从审批表单提取资产编号", "sp_no": sp_no} + + # 4. 提取当前审批人 + approver = _extract_current_approver(detail) + if not approver: + logger.warning(f"审批单 {sp_no} 未能提取当前审批人") + return {"success": False, "message": "未能提取当前审批人 userid", "sp_no": sp_no} + + # 5. 提取申请人 userid + applyer_userid = ( + detail.get("info", {}).get("applyer", {}).get("userid", "") + ) + + # 6. 核查资产年限 + asset_service = AssetService() + threshold = settings.asset_replacement_threshold_years + check_result = asset_service.check_device_age(asset_code, threshold) + + # 7. 构建并推送卡片消息 + description = _build_urge_description(check_result, applyer_userid) + card_url = ( + f"https://app.work.weixin.qq.com/wework_admin/approval_v3" + f"#/?template_id={ASSET_UPGRADE_TEMPLATE_ID}&sp_id={sp_no}" + ) + + # 使用 IT 支持应用的 access_token 发送消息(非审批应用 token) + wecom_service = WecomService(redis_client=redis) + await wecom_service.send_card_message( + user_id=approver, + title="IT资产升级审批 — 设备年限核查", + description=description, + url=card_url, + btntxt="去审批", + ) + + logger.info(f"资产年限核查推送成功: sp_no={sp_no}, approver={approver}") + return { + "success": True, + "message": "推送成功", + "sp_no": sp_no, + "approver": approver, + "asset_code": asset_code, + "check_result": check_result, + } + + except Exception as e: + logger.error(f"资产年限核查推送失败: sp_no={sp_no}, error={e}") + return {"success": False, "message": f"推送失败: {e}", "sp_no": sp_no} + + +# ============================================================================= +# API 端点 +# ============================================================================= + +@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse]) +async def get_approval_templates(): + """获取所有审批模板列表""" + return list(APPROVAL_TEMPLATES.values()) + + +@router.get("/approval/templates/{template_id}", response_model=ApprovalTemplateResponse) +async def get_approval_template(template_id: str): + """获取指定审批模板详情""" + if template_id not in APPROVAL_TEMPLATES: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="模板不存在") + return APPROVAL_TEMPLATES[template_id] + + +@router.get("/approval/templates/{template_id}/detail") +async def get_template_full_detail( + template_id: str, + redis: aioredis.Redis = Depends(get_redis) +): + """获取审批模板完整详情(控件结构)""" + if template_id not in APPROVAL_TEMPLATES: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="模板不存在") + + try: + token = await get_approval_token(redis) + detail = await get_template_detail(token, template_id) + return detail + except Exception as e: + from fastapi import HTTPException + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/approval/jump") +async def create_approval_jump(request: ApprovalJumpRequest): + """生成跳转审批链接""" + template = APPROVAL_TEMPLATES.get(request.template_id) + if not template: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="模板不存在") + + if template["type"] != "jump": + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="该模板不支持跳转方式") + + # 生成跳转URL(企微审批链接格式) + jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}" + + return success_response(data=ApprovalJumpResponse( + url=jump_url, + template_name=template["name"], + )) + + +@router.post("/approval/submit", response_model=ApprovalSubmitResponse) +async def submit_approval( + request: ApprovalSubmitRequest, + redis: aioredis.Redis = Depends(get_redis) +): + """API提交审批申请""" + template = APPROVAL_TEMPLATES.get(request.template_id) + if not template: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="模板不存在") + + if template["type"] != "api": + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="该模板不支持API提交") + + try: + # 1. 获取审批token + token = await get_approval_token(redis) + + # 2. 转换contents格式 + contents = [item.model_dump() for item in request.contents] + + # 3. 提交审批 + result = await submit_approval_api( + access_token=token, + template_id=request.template_id, + creator_userid=request.employee_id, + contents=contents, + use_template_approver=request.use_template_approver + ) + + return ApprovalSubmitResponse( + sp_no=result["sp_no"], + template_name=template["name"], + ) + + except Exception as e: + from fastapi import HTTPException + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/approval/callback") +async def approval_callback( + sp_no: str = Query(...), + sp_name: str = Query(...), + template_id: str = Query(...), + apply_time: int = Query(...), + applyer_userid: str = Query(...), + sp_status: int = Query(...), + status_change_event: int = Query(...), + redis: aioredis.Redis = Depends(get_redis), +): + """审批状态变化回调处理 + + 对应企微审批回调事件: sys_approval_change + + 状态变化类型 (status_change_event): + 1 - 提单 + 2 - 同意 + 3 - 驳回 + 4 - 转审 + 5 - 催办 + 6 - 撤销 + 8 - 通过后撤销 + 10 - 添加备注 + + 审批单状态 (sp_status): + 1 - 审批中 + 2 - 已通过 + 3 - 已驳回 + 4 - 已撤销 + 6 - 通过后撤销 + 7 - 已删除 + 10 - 已支付 + """ + logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}") + + # TODO: 根据业务需求处理审批状态变化 + # 例如: + # - 审批通过后,更新IT服务台待办状态 + # - 审批驳回后,通知申请人 + # - 审批撤销后,关闭相关工单 + + event_map = { + 1: "submitted", + 2: "approved", + 3: "rejected", + 4: "transferred", + 5: "reminded", + 6: "revoked", + 8: "revoked_after_approved", + 10: "commented" + } + + event_type = event_map.get(status_change_event, f"unknown_{status_change_event}") + logger.info(f"审批事件类型: {event_type}") + + # IT资产升级申请提单时,自动触发年限核查+推送(异步执行,不阻塞回调响应) + if status_change_event == 1 and template_id == ASSET_UPGRADE_TEMPLATE_ID: + logger.info(f"检测到IT资产升级申请提单: sp_no={sp_no}") + asyncio.create_task(_do_asset_urge(sp_no, redis)) + + return {"errcode": 0, "errmsg": "ok"} + + +@router.post("/approval/urge") +async def urge_approval( + request: ApprovalUrgeRequest, + redis: aioredis.Redis = Depends(get_redis), +): + """手动触发审批推送 — 资产年限核查 + 向当前审批人推送卡片消息。 + + 用于回调未触发或需重新推送的场景。内部调用 _do_asset_urge 执行完整逻辑。 + + Args: + request: 包含 sp_no(审批单号) + redis: Redis 客户端 + + Returns: + success_response: 包含推送结果和核查详情 + """ + result = await _do_asset_urge(request.sp_no, redis) + return success_response(data=result) + + +@router.get("/approval/keywords") +async def get_approval_keywords(): + """获取所有审批关键词(用于前端关键词检测)""" + keywords = [] + for template in APPROVAL_TEMPLATES.values(): + for kw in template["keywords"]: + keywords.append({ + "keyword": kw, + "template_id": template["id"], + "template_name": template["name"], + "type": template["type"], + }) + return success_response(data=keywords) + + +# ============================================================================= +# 审批意图识别(Dify + 关键词预过滤 + 降级兜底) +# ============================================================================= + +def _keyword_prefilter(text: str) -> bool: + """关键词预过滤:检查文本是否包含审批相关关键词(v2.0 收窄版)。 + + v2.0 变更(2026-07-13): + - 不再合并 APPROVAL_TEMPLATES 的 keywords(包含"借用""升级""外联"等泛化词) + - 仅使用 APPROVAL_PREFILTER_KEYWORDS(强意图词 + 复合专有词) + - 模板 keywords 仍保留在 KEYWORD_TO_APPROVAL_TYPE 中,仅用于 Dify 不可用时的降级兜底 + + Args: + text: 用户消息文本 + + Returns: + bool: 是否包含审批关键词 + """ + if not text: + return False + lower_text = text.lower() + # v2.0: 仅使用预过滤关键词列表,不合并模板 keywords + return any(kw.lower() in lower_text for kw in APPROVAL_PREFILTER_KEYWORDS) + + +def _fallback_detect(text: str) -> tuple[bool, float, Optional[str]]: + """关键词兜底:Dify 不可用时通过关键词匹配判断审批意图。 + + 遍历 KEYWORD_TO_APPROVAL_TYPE 映射,命中第一个关键词即返回对应审批类型。 + 置信度取 0.6(略低于阈值,但预过滤已通过说明有审批关键词)。 + + Args: + text: 用户消息文本 + + Returns: + tuple: (is_approval_request, confidence, approval_type) + """ + lower_text = (text or "").lower() + approval_type: Optional[str] = None + for kw, atype in KEYWORD_TO_APPROVAL_TYPE.items(): + if kw.lower() in lower_text: + approval_type = atype + break + # 预过滤已通过(说明有审批关键词),兜底返回 is_approval_request=True + return True, 0.6, approval_type + + +async def _call_dify_approval_intent(text: str, employee_id: str = "") -> dict: + """调用 Dify 审批意图识别应用(Dify 原生 API)。 + + 直接调用 Dify 原生 /v1/chat-messages 接口,绕过 Dify2OpenAI 代理。 + Dify2OpenAI 代理会将 JSON 响应序列化为 "[object Object]" 字符串, + 导致后端无法解析。使用原生 API 可获得正确的 JSON 响应。 + + Dify 应用的 System Prompt 已在 Dify 后台配置好,后端只需把用户消息传过去。 + 返回 JSON: {"is_approval_request": bool, "confidence": float, "approval_type": str|null} + + Args: + text: 用户消息文本 + employee_id: 员工 ID(可选,传给 Dify 的 user 字段) + + Returns: + dict: {"is_approval_request": bool, "confidence": float, "approval_type": str|None} + + Raises: + Exception: Dify 调用失败或响应解析失败 + """ + base_url = settings.approval_dify_base_url + api_key = settings.approval_dify_api_key + timeout = settings.approval_dify_timeout + + if not base_url or not api_key: + raise ValueError("Dify 审批意图识别应用未配置(APPROVAL_DIFY_BASE_URL / APPROVAL_DIFY_API_KEY)") + + # 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 API) + url = f"{base_url.rstrip('/')}/v1/chat-messages" + + body = { + "inputs": {}, # Dify 应用的输入变量(无自定义变量时为空) + "query": text, # 用户消息文本 + "response_mode": "blocking", # 阻塞模式,等待完整响应 + "user": employee_id or "approval_detection", # 用户标识 + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client: + response = await client.post(url, json=body, headers=headers) + response.raise_for_status() + data = response.json() + + # 解析 Dify 原生响应:answer 字段包含 AI 返回的文本(JSON 字符串) + answer = data.get("answer", "") + parsed = json.loads(answer) + + return { + "is_approval_request": bool(parsed.get("is_approval_request", False)), + "confidence": float(parsed.get("confidence", 0.0)), + "approval_type": parsed.get("approval_type"), + # v3 统一意图识别新增字段(向后兼容:旧 Prompt 无这些字段时取默认值) + "intent_type": str(parsed.get("intent_type", "chitchat")), + "business_category": parsed.get("business_category"), + "routing_confidence": float(parsed.get("routing_confidence", 0.0)), + } + + +@router.post("/approval/detect-intent") +async def detect_approval_intent(request: ApprovalDetectIntentRequest): + """审批意图检测端点。 + + 流程: + 1. 关键词预过滤 — 未命中直接返回 false(避免每条消息都调 Dify) + 2. 命中关键词 → 调用 Dify 审批意图识别应用 + 3. Dify 调用失败 → 降级为关键词匹配(兜底) + + Args: + request: 包含 text(用户消息)和可选的 employee_id + + Returns: + ApprovalDetectIntentResponse: 检测结果 + """ + text = request.text or "" + + # 1. 关键词预过滤 + if not _keyword_prefilter(text): + return success_response(data=ApprovalDetectIntentResponse( + is_approval_request=False, + confidence=0.0, + approval_type=None, + source="keyword_prefilter", + intent_type="chitchat", + business_category=None, + routing_confidence=0.0, + )) + + # 2. 调用 Dify 意图识别 + try: + result = await _call_dify_approval_intent(text, request.employee_id or "") + # 检查置信度阈值 + threshold = settings.approval_confidence_threshold + is_approval = result["is_approval_request"] and result["confidence"] >= threshold + logger.info( + f"审批意图检测(Dify): is_approval={is_approval}, " + f"confidence={result['confidence']}, type={result.get('approval_type')}, " + f"intent_type={result.get('intent_type')}, " + f"business_category={result.get('business_category')}, " + f"routing_confidence={result.get('routing_confidence')}" + ) + return success_response(data=ApprovalDetectIntentResponse( + is_approval_request=is_approval, + confidence=result["confidence"], + approval_type=result.get("approval_type"), + source="dify", + intent_type=result.get("intent_type", "chitchat"), + business_category=result.get("business_category"), + routing_confidence=result.get("routing_confidence", 0.0), + )) + except Exception as e: + logger.warning(f"Dify 审批意图识别失败,降级为关键词匹配: {e}") + # 3. 降级为关键词匹配 + is_approval, confidence, approval_type = _fallback_detect(text) + logger.info( + f"审批意图检测(兜底): is_approval={is_approval}, " + f"confidence={confidence}, type={approval_type}" + ) + return success_response(data=ApprovalDetectIntentResponse( + is_approval_request=is_approval, + confidence=confidence, + approval_type=approval_type, + source="fallback", + intent_type="approval" if is_approval else "chitchat", + business_category=None, + routing_confidence=0.0, + )) diff --git a/backend/app/api/auth_qrcode.py b/backend/app/api/auth_qrcode.py index a73ce64..58c848a 100644 --- a/backend/app/api/auth_qrcode.py +++ b/backend/app/api/auth_qrcode.py @@ -294,6 +294,7 @@ async def scan_qrcode( 登录成功 - IT智能服务台 + @@ -319,22 +318,34 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;

你好,{user_name}
请返回电脑端查看

-
-签名: {'成功' if jsapi_signature else '未生成'}
-URL: {current_url}
-状态: JS加载中... -
+ + diff --git a/frontend-agent/src/api/employee.ts b/frontend-agent/src/api/employee.ts index 440aba7..20c4710 100644 --- a/frontend-agent/src/api/employee.ts +++ b/frontend-agent/src/api/employee.ts @@ -25,16 +25,20 @@ export interface DirectoryEmployee { /** 组织架构树节点 */ export interface OrgTreeNode { - /** 节点 ID(部门名为部门节点 ID,员工 UserID 为叶子节点 ID) */ + /** 节点 ID(部门为 dept_{id} 前缀,员工 UserID 为叶子节点 ID) */ id: string /** 节点显示名称(部门名或员工姓名) */ label: string - /** 子节点列表(仅部门节点有,员工叶子节点无此字段) */ + /** 子节点列表(仅部门节点有,混合子部门+员工;员工叶子节点无此字段) */ children?: OrgTreeNode[] /** 是否为叶子节点(员工节点为 true,部门节点不设置) */ isLeaf?: boolean /** 部门名称(仅员工叶子节点携带,用于前端显示) */ department?: string + /** 企微原始部门ID(仅部门节点有) */ + dept_id?: number | null + /** 父部门ID(仅部门节点有) */ + parentid?: number } // -------------------------------------------------------------------------- diff --git a/frontend-agent/src/api/index.ts b/frontend-agent/src/api/index.ts index d17ec73..7b86bef 100644 --- a/frontend-agent/src/api/index.ts +++ b/frontend-agent/src/api/index.ts @@ -8,7 +8,7 @@ // ============================================================================= import axios from 'axios' -import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios' +import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios' // ElementPlus 消息提示 import { ElMessage } from 'element-plus' @@ -261,5 +261,22 @@ apiClient.interceptors.response.use( } ) -// 导出 Axios 实例,供 API 模块使用 -export default apiClient +// -------------------------------------------------------------------------- +// 类型修正:响应拦截器已解包 AxiosResponse,重写实例类型使调用方获得正确推断 +// -------------------------------------------------------------------------- +// 问题:响应拦截器在运行时返回 res.data(已解包的内部数据),但 TypeScript +// 仍认为 apiClient.get() 返回 Promise>,导致调用方 +// const response: AxiosResponse = await apiClient.get(...) 的类型与 +// 函数声明的返回类型不匹配,产生大量编译错误。 +// 修复:导出带正确返回类型的接口,使 get/post/put/delete 返回 Promise。 +interface UnwrappedApiClient + extends Omit { + get(url: string, config?: AxiosRequestConfig): Promise + post(url: string, data?: any, config?: AxiosRequestConfig): Promise + put(url: string, data?: any, config?: AxiosRequestConfig): Promise + delete(url: string, config?: AxiosRequestConfig): Promise + patch(url: string, data?: any, config?: AxiosRequestConfig): Promise +} + +// 导出 Axios 实例(带修正后的类型),供 API 模块使用 +export default apiClient as unknown as UnwrappedApiClient diff --git a/frontend-agent/src/api/message.ts b/frontend-agent/src/api/message.ts index 478103a..48eda6e 100644 --- a/frontend-agent/src/api/message.ts +++ b/frontend-agent/src/api/message.ts @@ -7,7 +7,6 @@ // ============================================================================= import apiClient from './index' -import type { AxiosResponse } from 'axios' // -------------------------------------------------------------------------- // TypeScript 类型定义 — 与后端 Schema 保持一致 @@ -63,6 +62,16 @@ export interface MessageListData { has_more: boolean } +/** 历史消息列表响应(跨会话聚合,对应后端 HistoryMessageListResponse) */ +export interface HistoryMessageListData { + /** 消息列表(按时间倒序,最新在前) */ + items: Message[] + /** 是否还有更多历史消息 */ + has_more: boolean + /** 会话ID → 首条消息摘要(前20字) */ + conversation_summaries: Record +} + // -------------------------------------------------------------------------- // API 函数 // -------------------------------------------------------------------------- @@ -85,7 +94,7 @@ export async function getMessages( before?: string } ): Promise { - const response: AxiosResponse = await apiClient.get( + const response = await apiClient.get( `/conversations/${conversationId}/messages`, { params } ) @@ -129,7 +138,7 @@ export async function sendMessage( } console.log('[sendMessage] 请求体:', requestBody) - const response: AxiosResponse = await apiClient.post( + const response = await apiClient.post( `/conversations/${conversationId}/messages`, requestBody, { @@ -157,7 +166,7 @@ export async function pollMessages( if (afterMessageId) { params.after_message_id = afterMessageId } - const response: AxiosResponse = await apiClient.get( + const response = await apiClient.get( `/conversations/${conversationId}/messages/poll`, { params } ) @@ -171,7 +180,7 @@ export async function pollMessages( * @returns 撤回结果 */ export async function recallMessage(messageId: string): Promise { - const response: AxiosResponse = await apiClient.post( + const response = await apiClient.post( `/messages/${messageId}/recall` ) return response @@ -184,7 +193,7 @@ export async function recallMessage(messageId: string): Promise { * @returns 删除结果 */ export async function deleteMessage(messageId: string): Promise { - const response: AxiosResponse = await apiClient.delete( + const response = await apiClient.delete( `/messages/${messageId}` ) return response @@ -197,7 +206,7 @@ export async function deleteMessage(messageId: string): Promise { * @returns 标记结果 */ export async function markConversationRead(conversationId: string): Promise { - const response: AxiosResponse = await apiClient.post( + const response = await apiClient.post( `/conversations/${conversationId}/mark-read` ) return response @@ -216,7 +225,7 @@ export async function uploadImage(file: File): Promise<{ }> { const formData = new FormData() formData.append('file', file) - const response: AxiosResponse = await apiClient.post( + const response = await apiClient.post( '/messages/image', formData, // ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data @@ -238,7 +247,7 @@ export async function uploadMessageFile(file: File): Promise<{ }> { const formData = new FormData() formData.append('file', file) - const response: AxiosResponse = await apiClient.post( + const response = await apiClient.post( '/messages/file', formData, // ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data @@ -246,3 +255,29 @@ export async function uploadMessageFile(file: File): Promise<{ ) return response } + +/** + * 获取员工历史消息(跨会话聚合) + * 将同一员工的所有会话消息合并为一条时间线,按时间排序 + * + * @param employeeId - 员工企微 UserID + * @param params - 查询参数 + * @param params.limit - 每页消息数量(默认50) + * @param params.before - 游标消息ID,加载此消息之前的消息(向上翻页) + * @param params.current_conversation_id - 当前会话ID(用于标记当前会话) + * @returns 历史消息列表数据 + */ +export async function getHistoryMessages( + employeeId: string, + params?: { + limit?: number + before?: string + current_conversation_id?: string + } +): Promise { + const response = await apiClient.get( + `/employees/${employeeId}/history-messages`, + { params } + ) + return response +} diff --git a/frontend-agent/src/api/wingman.ts b/frontend-agent/src/api/wingman.ts index 17702a1..46ba8d5 100644 --- a/frontend-agent/src/api/wingman.ts +++ b/frontend-agent/src/api/wingman.ts @@ -4,11 +4,22 @@ // 说明:封装与 AI Wingman(坐席智能副驾驶)相关的 HTTP 请求 // 对应后端 API:/api/conversations/{id}/wingman // 包括:生成草稿回复、生成会话摘要、生成标签建议 +// 以及 AI 辅助消息框:自动补齐、语气调整、文字润色、智能改写 // ============================================================================= import apiClient from './index' import type { AxiosResponse } from 'axios' +// AI 辅助消息框类型导入 +import type { + AutocompleteResult, + ToneType, + ToneAdjustResult, + PolishAction, + PolishResult, + RewriteResult, +} from '@/types/ai-assist' + // -------------------------------------------------------------------------- // TypeScript 类型定义 — 与后端 Wingman API 响应格式保持一致 // -------------------------------------------------------------------------- @@ -108,3 +119,120 @@ export async function saveConversationTags( ) return response } + +// -------------------------------------------------------------------------- +// AI 辅助消息框 API(4 个功能) +// -------------------------------------------------------------------------- + +/** + * 自动补齐 — 根据当前输入和上下文生成下一句补齐建议。 + * + * 前端在输入停顿 800ms 后调用,通过 AbortController 支持请求取消 + * (快速连续输入时 abort 旧请求,只保留最新)。 + * + * @param convId - 会话ID + * @param currentText - 当前输入文本 + * @param cursorPosition - 光标位置(默认 0) + * @param maxLength - 补齐最大长度(默认 80) + * @param signal - AbortSignal(可选,用于请求取消) + * @returns 补齐结果(completion + confidence) + */ +export async function autocomplete( + convId: string, + currentText: string, + cursorPosition: number = 0, + maxLength: number = 80, + signal?: AbortSignal, +): Promise { + const response: AxiosResponse = await apiClient.post( + `/conversations/${convId}/wingman/autocomplete`, + { + current_text: currentText, + cursor_position: cursorPosition, + max_length: maxLength, + }, + { signal }, + ) + return response as unknown as AutocompleteResult +} + +/** + * 语气调整 — 将选中的文字改写为指定语气风格。 + * + * @param convId - 会话ID + * @param selectedText - 选中的文字 + * @param fullText - 输入框完整内容(供 AI 理解上下文) + * @param tone - 目标语气:professional/friendly/concise + * @param signal - AbortSignal(可选) + * @returns 语气调整结果(rewritten_text + changes_summary) + */ +export async function adjustTone( + convId: string, + selectedText: string, + fullText: string, + tone: ToneType, + signal?: AbortSignal, +): Promise { + const response: AxiosResponse = await apiClient.post( + `/conversations/${convId}/wingman/tone-adjust`, + { + selected_text: selectedText, + full_text: fullText, + tone, + }, + { signal }, + ) + return response as unknown as ToneAdjustResult +} + +/** + * 文字润色 — 对输入文字进行扩写/压缩/纠错处理。 + * + * @param convId - 会话ID + * @param text - 待润色文字 + * @param action - 润色操作:expand/compress/correct + * @param signal - AbortSignal(可选) + * @returns 润色结果(polished_text + changes_summary) + */ +export async function polishText( + convId: string, + text: string, + action: PolishAction, + signal?: AbortSignal, +): Promise { + const response: AxiosResponse = await apiClient.post( + `/conversations/${convId}/wingman/polish`, + { + text, + action, + conversation_context: true, + }, + { signal }, + ) + return response as unknown as PolishResult +} + +/** + * 智能改写 — 基于对话上下文和知识库生成多个备选回复。 + * + * @param convId - 会话ID + * @param currentText - 当前输入文本(可为空,此时仅基于上下文生成) + * @param signal - AbortSignal(可选) + * @returns 改写结果(versions 数组,通常 3 个) + */ +export async function rewriteVersions( + convId: string, + currentText: string, + signal?: AbortSignal, +): Promise { + const response: AxiosResponse = await apiClient.post( + `/conversations/${convId}/wingman/rewrite`, + { + current_text: currentText, + generate_count: 3, + include_knowledge: true, + }, + { signal }, + ) + return response as unknown as RewriteResult +} diff --git a/frontend-agent/src/components/chat/ChatArea.vue b/frontend-agent/src/components/chat/ChatArea.vue index 3858dfc..59b2575 100644 --- a/frontend-agent/src/components/chat/ChatArea.vue +++ b/frontend-agent/src/components/chat/ChatArea.vue @@ -20,12 +20,15 @@ :conversation="conversationStore.currentConversation" :available-agents="agentStore.availableAgents" :can-invite-collaborator="canInviteCollaborator" + :history-mode="conversationStore.historyMode" + :history-loading="conversationStore.historyLoading" @assign="handleAssign" @resolve="handleResolve" @toggle-pin="handleTogglePin" @toggle-todo="handleToggleTodo" @transfer="handleTransfer" @invite="inviteDialogVisible = true" + @toggle-history="handleToggleHistory" /> @@ -72,27 +75,53 @@ -
+
- -
+ +
加载消息中...
- -