chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# =============================================================================
# 企微IT智能服务台 — 后端 Docker 镜像构建文件
# =============================================================================
# 说明:基于 Python 3.12 构建后端镜像
# 用法:docker build -t wecom-it-desk-backend .
# =============================================================================
# --------------------------------------------------------------------------
# 第一阶段:构建阶段
# --------------------------------------------------------------------------
FROM python:3.12-slim AS builder
# 设置工作目录
WORKDIR /app
# 安装系统依赖(psycopg2 编译需要 + qrcode 图片处理需要 + healthcheck 需要 curl
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev libjpeg-dev zlib1g-dev curl && \
rm -rf /var/lib/apt/lists/*
# 复制依赖声明文件并安装(利用 Docker 层缓存,依赖不变则不重新安装)
# 使用清华大学 PyPI 镜像源,解决公司内网下载 PyPI 官方源超时问题
COPY requirements.txt .
RUN pip install --no-cache-dir \
--timeout 120 \
--retries 5 \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
-r requirements.txt
# --------------------------------------------------------------------------
# 第二阶段:运行阶段(更小的镜像体积)
# --------------------------------------------------------------------------
FROM python:3.12-slim
# 设置标签信息
LABEL maintainer="IT服务台开发团队"
LABEL description="企微IT智能服务台后端服务"
# 安装运行时依赖(psycopg2 运行时需要 libpq + healthcheck 需要 curl
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 curl && \
rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 从构建阶段复制已安装的 Python 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# 复制项目代码
COPY . .
# 暴露端口
EXPOSE 8000
# 启动命令(Docker Compose 中会覆盖为 alembic upgrade head + uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+42
View File
@@ -0,0 +1,42 @@
# Alembic database migration configuration
# Usage: alembic upgrade head
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://wecom:wecom_secret@localhost:5432/wecom_it_desk
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+62
View File
@@ -0,0 +1,62 @@
# =============================================================================
# Alembic migration environment
# Handles both sync (alembic CLI) and async (app runtime) database URLs
# =============================================================================
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.config import settings
from app.database import Base
import app.models # noqa: F401
config = context.config
# Convert async URL to sync for alembic CLI operations
# aiosqlite -> sqlite, asyncpg -> psycopg2
db_url = settings.database_url
db_url = db_url.replace("+aiosqlite", "").replace("+asyncpg", "")
config.set_main_option("sqlalchemy.url", db_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Generate SQL scripts without connecting to the database."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Connect to the database and run migrations."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
@@ -0,0 +1,47 @@
"""add media fields to messages table
为消息表添加媒体文件相关字段,支持图片、语音、文件等非文本消息。
新增字段:
- media_id: 企微媒体文件ID3天有效)
- media_url: 本地存储的媒体文件URL
- file_name: 文件名
- file_size: 文件大小(字节)
- extra_data: 扩展元数据(JSON
Revision ID: 002_media_fields
Revises: 6d5520491644
Create Date: 2026-06-03 17:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002_media_fields'
down_revision = '6d5520491644'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加媒体文件相关字段到 messages 表。"""
# 企微媒体文件ID(图片/语音/视频消息携带,3天有效)
op.add_column('messages', sa.Column('media_id', sa.String(256), nullable=True, comment='企微媒体文件ID3天有效)'))
# 本地存储的媒体文件URL(下载后保存到服务器/NAS的访问路径)
op.add_column('messages', sa.Column('media_url', sa.String(512), nullable=True, comment='本地存储的媒体文件URL'))
# 文件名(文件消息携带)
op.add_column('messages', sa.Column('file_name', sa.String(256), nullable=True, comment='文件名'))
# 文件大小(字节)
op.add_column('messages', sa.Column('file_size', sa.Integer(), nullable=True, comment='文件大小(字节)'))
# 扩展元数据(JSON格式,存储各消息类型的额外信息)
op.add_column('messages', sa.Column('extra_data', sa.JSON(), nullable=True, comment='扩展元数据(JSON'))
def downgrade() -> None:
"""移除媒体文件相关字段。"""
op.drop_column('messages', 'extra_data')
op.drop_column('messages', 'file_size')
op.drop_column('messages', 'file_name')
op.drop_column('messages', 'media_url')
op.drop_column('messages', 'media_id')
@@ -0,0 +1,40 @@
"""add suggestion_action field to messages table
为消息表添加 suggestion_action 字段,用于追踪坐席对 AI 建议的操作行为。
取值范围:accepted(采纳)/ edited(编辑后采纳)/ ignored(忽略)
新增字段:
- suggestion_action: VARCHAR(20), nullable, 坐席对AI建议的操作行为
Revision ID: 003_suggestion_action
Revises: 002_media_fields
Create Date: 2026-07-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '003_suggestion_action'
down_revision = '002_media_fields'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 suggestion_action 字段到 messages 表。"""
# 坐席对 AI 建议的操作行为(accepted/edited/ignored
op.add_column(
'messages',
sa.Column(
'suggestion_action',
sa.String(20),
nullable=True,
comment='AI建议操作行为: accepted/edited/ignored',
)
)
def downgrade() -> None:
"""移除 suggestion_action 字段。"""
op.drop_column('messages', 'suggestion_action')
@@ -0,0 +1,58 @@
"""add participants field to conversations table
为会话表添加 participants JSON 字段,支持邀请功能(P0-09~P0-11)。
与 collaborating_agent_ids(摇人 = 坐席间协作)独立,
participants 存储被邀请的员工/部门列表。
新增字段:
- participants: JSON, 非空, 默认空列表, 被邀请参与会话的人员列表
数据格式:
[
{
"id": "employee_user_id",
"name": "员工姓名",
"department": "部门名称",
"type": "employee", # employee 或 department
"joined": false, # 是否已加入会话
"joined_at": null, # 加入时间
"invited_by": "agent_id" # 邀请人坐席ID
}
]
Revision ID: 004_participants
Revises: 003_suggestion_action
Create Date: 2026-07-14 14:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004_participants'
down_revision = '003_suggestion_action'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 participants 字段到 conversations 表。"""
# 被邀请参与会话的人员列表(JSON 数组)
# 与 collaborating_agent_ids 区别:
# collaborating_agent_ids = 坐席→坐席协作(摇人)
# participants = 坐席→员工/部门(邀请)
op.add_column(
'conversations',
sa.Column(
'participants',
sa.JSON,
nullable=False,
server_default='[]', # 默认空数组
comment='被邀请参与会话的人员列表(邀请功能)',
)
)
def downgrade() -> None:
"""移除 participants 字段。"""
op.drop_column('conversations', 'participants')
@@ -0,0 +1,43 @@
"""add reply_to_id field to messages table
为消息表添加 reply_to_id 字段,支持消息引用回复功能(M1)。
当消息是对某条消息的回复时,此字段指向被回复的消息ID。
新增字段:
- reply_to_id: VARCHAR(36), nullable, 被回复的消息ID
前端展示逻辑:
- reply_to_id 非空时,在消息气泡上方显示被回复消息的摘要
- 点击摘要可滚动到被回复的消息位置
Revision ID: 005_reply_to_id
Revises: 004_participants
Create Date: 2026-07-14 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '005_reply_to_id'
down_revision = '004_participants'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 reply_to_id 字段到 messages 表。"""
op.add_column(
'messages',
sa.Column(
'reply_to_id',
sa.String(36),
nullable=True,
comment='引用回复:被回复的消息ID',
)
)
def downgrade() -> None:
"""移除 reply_to_id 字段。"""
op.drop_column('messages', 'reply_to_id')
@@ -0,0 +1,116 @@
"""admin extension — 管理后台数据库扩展迁移
新增 config_change_logs 表(配置变更日志)。
扩展 agents 表:新增 role(角色)和 skill_tags(技能标签)字段。
扩展 quick_reply_templates 表:新增 status(审核状态)、version(版本号)、
submitted_by(提交人)字段。
Revision ID: 006_admin_ext
Revises: 005_reply_to_id
Create Date: 2026-07-15 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '006_admin_ext'
down_revision = '005_reply_to_id'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""执行管理后台数据库扩展迁移。"""
# 1. 创建 config_change_logs 表
op.create_table(
'config_change_logs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('config_key', sa.String(128), nullable=False, comment='配置键'),
sa.Column('old_value', sa.Text, nullable=False, server_default='', comment='变更前的值'),
sa.Column('new_value', sa.Text, nullable=False, server_default='', comment='变更后的值'),
sa.Column('changed_by', sa.String(36), nullable=False, comment='变更操作人 agent_id'),
sa.Column('changed_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(), comment='变更时间'),
)
# 创建索引
op.create_index('idx_ccl_config_key', 'config_change_logs', ['config_key'])
op.create_index('idx_ccl_changed_at', 'config_change_logs', ['changed_at'])
# 2. 给 agents 表新增 role 字段
op.add_column(
'agents',
sa.Column(
'role',
sa.String(20),
nullable=False,
server_default='agent',
comment='角色:admin=组长, agent=坐席',
)
)
# 3. 给 agents 表新增 skill_tags 字段
op.add_column(
'agents',
sa.Column(
'skill_tags',
sa.JSON,
nullable=False,
server_default='[]',
comment='技能标签列表(电脑/软件/外设/网络/安全/资产/其他)',
)
)
# 4. 给 quick_reply_templates 表新增 status 字段
op.add_column(
'quick_reply_templates',
sa.Column(
'status',
sa.String(20),
nullable=False,
server_default='approved',
comment='状态:draft/pending_review/approved/rejected',
)
)
# 5. 给 quick_reply_templates 表新增 version 字段
op.add_column(
'quick_reply_templates',
sa.Column(
'version',
sa.Integer(),
nullable=False,
server_default='1',
comment='版本号,每次审核通过后 +1',
)
)
# 6. 给 quick_reply_templates 表新增 submitted_by 字段
op.add_column(
'quick_reply_templates',
sa.Column(
'submitted_by',
sa.String(36),
nullable=True,
comment='提交人 agent_id',
)
)
def downgrade() -> None:
"""回滚管理后台数据库扩展迁移。"""
# 删除 quick_reply_templates 新增字段
op.drop_column('quick_reply_templates', 'submitted_by')
op.drop_column('quick_reply_templates', 'version')
op.drop_column('quick_reply_templates', 'status')
# 删除 agents 新增字段
op.drop_column('agents', 'skill_tags')
op.drop_column('agents', 'role')
# 删除 config_change_logs 表索引和表
op.drop_index('idx_ccl_changed_at', table_name='config_change_logs')
op.drop_index('idx_ccl_config_key', table_name='config_change_logs')
op.drop_table('config_change_logs')
+104
View File
@@ -0,0 +1,104 @@
"""role system — 统一入口角色系统迁移
新增 roles 表(角色定义)。
新增 user_roles 表(用户角色关联)。
新增 role_mapping_rules 表(角色映射规则)。
预置三个基础角色:user、agent、admin。
Revision ID: 007_role_sys
Revises: 006_admin_ext
Create Date: 2026-06-12 23:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '007_role_sys'
down_revision = '006_admin_ext'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""执行角色系统迁移。"""
# 1. 创建 roles 表
op.create_table(
'roles',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(50), unique=True, nullable=False, comment='角色标识:user/agent/admin'),
sa.Column('display_name', sa.String(100), nullable=False, comment='显示名称:用户/坐席/管理员'),
sa.Column('description', sa.Text, nullable=True, comment='角色描述'),
sa.Column('permissions', sa.JSON, nullable=False, server_default='[]', comment='权限列表'),
sa.Column('is_default', sa.Boolean, nullable=False, server_default='0', comment='是否默认角色'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='更新时间'),
)
# 2. 创建 user_roles 表
op.create_table(
'user_roles',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('employee_id', sa.String(100), nullable=False, comment='企微 UserID'),
sa.Column('role_id', sa.String(36), sa.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, comment='角色 ID'),
sa.Column('source', sa.String(50), nullable=False, comment='角色来源:auto/tag/ehr/manual'),
sa.Column('assigned_by', sa.String(100), nullable=True, comment='分配者'),
sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='分配时间'),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True, comment='过期时间'),
sa.UniqueConstraint('employee_id', 'role_id', name='uq_user_role'),
)
# 创建索引
op.create_index('idx_user_roles_employee_id', 'user_roles', ['employee_id'])
op.create_index('idx_user_roles_role_id', 'user_roles', ['role_id'])
# 3. 创建 role_mapping_rules 表
op.create_table(
'role_mapping_rules',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('role_id', sa.String(36), sa.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, comment='目标角色 ID'),
sa.Column('source_type', sa.String(50), nullable=False, comment='来源类型:wecom_tag/ehr_position'),
sa.Column('source_value', sa.String(200), nullable=False, comment='来源值:标签名/岗位关键词'),
sa.Column('priority', sa.Integer(), nullable=False, server_default='0', comment='优先级'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1', comment='是否启用'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='创建时间'),
)
# 创建索引
op.create_index('idx_role_mapping_rules_role_id', 'role_mapping_rules', ['role_id'])
op.create_index('idx_role_mapping_rules_source_type', 'role_mapping_rules', ['source_type'])
# 4. 预置三个基础角色
# 注意:使用 op.execute 直接插入数据,因为 server_default 不适用于 Python 端生成的 UUID
# PostgreSQL 使用 NOW() 替代 SQLite 的 datetime('now')
op.execute("""
INSERT INTO roles (id, name, display_name, description, permissions, is_default, created_at, updated_at) VALUES
('role_user_001', 'user', '用户', '所有在职员工默认角色,可提交工单、查看进度、浏览知识库', '["ticket.create", "ticket.view", "knowledge.view"]', TRUE, NOW(), NOW()),
('role_agent_001', 'agent', '坐席', 'IT支持人员,可处理会话、使用AI辅助、管理工单', '["conversation.manage", "ticket.assign", "knowledge.edit", "ai.wingman"]', FALSE, NOW(), NOW()),
('role_admin_001', 'admin', '管理员', '系统管理员,可配置系统、管理权限、查看数据分析', '["system.config", "user.manage", "role.manage", "analytics.view"]', FALSE, NOW(), NOW())
""")
# 5. 预置默认映射规则(企微标签 → agent 角色)
op.execute("""
INSERT INTO role_mapping_rules (id, role_id, source_type, source_value, priority, is_active, created_at) VALUES
('rule_agent_tag_001', 'role_agent_001', 'wecom_tag', 'IT坐席', 10, TRUE, NOW()),
('rule_agent_ehr_001', 'role_agent_001', 'ehr_position', 'IT支持', 10, TRUE, NOW()),
('rule_agent_ehr_002', 'role_agent_001', 'ehr_position', 'IT运维', 10, TRUE, NOW()),
('rule_agent_ehr_003', 'role_agent_001', 'ehr_position', '技术支持', 10, TRUE, NOW())
""")
def downgrade() -> None:
"""回滚角色系统迁移。"""
# 删除 role_mapping_rules 表索引和表
op.drop_index('idx_role_mapping_rules_source_type', table_name='role_mapping_rules')
op.drop_index('idx_role_mapping_rules_role_id', table_name='role_mapping_rules')
op.drop_table('role_mapping_rules')
# 删除 user_roles 表索引和表
op.drop_index('idx_user_roles_role_id', table_name='user_roles')
op.drop_index('idx_user_roles_employee_id', table_name='user_roles')
op.drop_table('user_roles')
# 删除 roles 表
op.drop_table('roles')
@@ -0,0 +1,199 @@
"""initial_all_tables
Revision ID: 6d5520491644
Revises:
Create Date: 2026-06-03 17:28:43.238581
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6d5520491644'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agents',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=64), nullable=False, comment='企微用户ID(唯一)'),
sa.Column('name', sa.String(length=128), nullable=False, comment='坐席姓名'),
sa.Column('status', sa.String(length=20), nullable=False, comment='坐席状态: online/offline/busy'),
sa.Column('current_load', sa.Integer(), nullable=False, comment='当前服务会话数'),
sa.Column('max_load', sa.Integer(), nullable=False, comment='最大同时服务数'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
)
op.create_table('approval_links',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:IT/HR/行政/财务'),
sa.Column('title', sa.String(length=128), nullable=False, comment='审批名称'),
sa.Column('url', sa.Text(), nullable=False, comment='审批链接'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_al_category', 'approval_links', ['category'], unique=False)
op.create_table('conversations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('corp_id', sa.String(length=64), nullable=False, comment='企业微信企业ID(主企业或下游企业)'),
sa.Column('employee_id', sa.String(length=64), nullable=False, comment='企微员工UserID'),
sa.Column('employee_name', sa.String(length=128), nullable=False, comment='员工姓名'),
sa.Column('department', sa.String(length=256), nullable=False, comment='部门'),
sa.Column('position', sa.String(length=128), nullable=False, comment='岗位'),
sa.Column('level', sa.String(length=64), nullable=False, comment='等级'),
sa.Column('status', sa.String(length=20), nullable=False, comment='会话状态: ai_handling/queued/serving/resolved'),
sa.Column('is_vip', sa.Boolean(), nullable=False, comment='VIP标记'),
sa.Column('is_pinned', sa.Boolean(), nullable=False, comment='置顶标记'),
sa.Column('is_todo', sa.Boolean(), nullable=False, comment='代办标记'),
sa.Column('urgency_score', sa.Integer(), nullable=False, comment='紧急度1-5'),
sa.Column('tags', sa.JSON(), nullable=False, comment='标签集合'),
sa.Column('assigned_agent_id', sa.String(length=64), nullable=True, comment='分配的坐席ID'),
sa.Column('collaborating_agent_ids', sa.JSON(), nullable=False, comment='协作坐席ID列表'),
sa.Column('ai_substantive_reply_count', sa.Integer(), nullable=False, comment='AI实质性回复计数(满3次可呼叫坐席)'),
sa.Column('last_message_at', sa.DateTime(timezone=True), nullable=True, comment='最后消息时间'),
sa.Column('last_message_summary', sa.String(length=256), nullable=False, comment='最后消息摘要'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_conversations_assigned_agent', 'conversations', ['assigned_agent_id'], unique=False)
op.create_index('idx_conversations_corp_id', 'conversations', ['corp_id'], unique=False)
op.create_index('idx_conversations_employee_id', 'conversations', ['employee_id'], unique=False)
op.create_index('idx_conversations_last_message_at', 'conversations', ['last_message_at'], unique=False)
op.create_index('idx_conversations_status', 'conversations', ['status'], unique=False)
op.create_index('idx_conversations_urgency_score', 'conversations', ['urgency_score'], unique=False)
op.create_table('employees',
sa.Column('id', sa.String(length=36), nullable=False, comment='员工记录唯一标识'),
sa.Column('corp_id', sa.String(length=64), nullable=False, comment='企业微信企业ID'),
sa.Column('employee_id', sa.String(length=64), nullable=False, comment='企微员工UserID(企业内唯一)'),
sa.Column('name', sa.String(length=128), nullable=False, comment='员工姓名'),
sa.Column('department', sa.String(length=512), nullable=False, comment='部门ID列表(JSON数组)'),
sa.Column('position', sa.String(length=128), nullable=False, comment='岗位'),
sa.Column('mobile', sa.String(length=32), nullable=False, comment='手机号'),
sa.Column('email', sa.String(length=128), nullable=False, comment='邮箱'),
sa.Column('avatar', sa.String(length=512), nullable=False, comment='头像URL'),
sa.Column('status', sa.Integer(), nullable=False, comment='激活状态: 1=已激活, 2=已禁用, 4=未激活'),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True, comment='最后登录时间'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('corp_id', 'employee_id', name='uq_employee_corp')
)
op.create_index('idx_employees_corp_id', 'employees', ['corp_id'], unique=False)
op.create_index('idx_employees_employee_id', 'employees', ['employee_id'], unique=False)
op.create_table('funny_phrases',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('scene', sa.String(length=64), nullable=False, comment='触发场景: shake/keyword/waiting/connected/timeout/vip'),
sa.Column('content', sa.Text(), nullable=False, comment='话术内容'),
sa.Column('tone', sa.String(length=32), nullable=False, comment='语气标签'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('is_active', sa.Boolean(), nullable=False, comment='是否启用'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_fp_scene', 'funny_phrases', ['scene'], unique=False)
op.create_table('quick_reply_templates',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:账号/网络/软件/硬件/通用'),
sa.Column('title', sa.String(length=128), nullable=False, comment='模板标题'),
sa.Column('content', sa.Text(), nullable=False, comment='模板内容,支持变量如 {employee_name}'),
sa.Column('variables', sa.JSON(), nullable=False, comment='可用变量列表'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_qr_category', 'quick_reply_templates', ['category'], unique=False)
op.create_table('software_downloads',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:办公/开发/安全/工具'),
sa.Column('name', sa.String(length=128), nullable=False, comment='软件名称'),
sa.Column('version', sa.String(length=32), nullable=False, comment='版本号'),
sa.Column('platform', sa.String(length=32), nullable=False, comment='平台: Windows/Mac/Linux/全平台'),
sa.Column('download_url', sa.Text(), nullable=False, comment='下载链接'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_sd_category', 'software_downloads', ['category'], unique=False)
op.create_table('system_configs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('config_key', sa.String(length=128), nullable=False, comment='配置键'),
sa.Column('config_value', sa.Text(), nullable=False, comment='配置值(JSON字符串或纯文本)'),
sa.Column('description', sa.String(length=256), nullable=False, comment='配置说明'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('config_key')
)
op.create_table('agent_notes',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False, comment='所属会话ID'),
sa.Column('agent_id', sa.String(length=64), nullable=False, comment='坐席ID'),
sa.Column('content', sa.Text(), nullable=False, comment='备注内容'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_an_conversation', 'agent_notes', ['conversation_id'], unique=False)
op.create_table('messages',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False, comment='所属会话ID'),
sa.Column('sender_type', sa.String(length=20), nullable=False, comment='发送者类型: employee/agent/ai/system'),
sa.Column('sender_id', sa.String(length=64), nullable=False, comment='发送者ID'),
sa.Column('sender_name', sa.String(length=128), nullable=False, comment='发送者姓名'),
sa.Column('content', sa.Text(), nullable=False, comment='消息内容'),
sa.Column('msg_type', sa.String(length=20), nullable=False, comment='消息类型: text/image/file/system'),
sa.Column('ai_suggestion', sa.Boolean(), nullable=False, comment='是否为AI建议'),
sa.Column('is_read', sa.Boolean(), nullable=False, comment='是否已读'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_messages_conversation_created', 'messages', ['conversation_id', 'created_at'], unique=False)
op.create_index('idx_messages_conversation_id', 'messages', ['conversation_id'], unique=False)
op.create_index('idx_messages_created_at', 'messages', ['created_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_messages_created_at', table_name='messages')
op.drop_index('idx_messages_conversation_id', table_name='messages')
op.drop_index('idx_messages_conversation_created', table_name='messages')
op.drop_table('messages')
op.drop_index('idx_an_conversation', table_name='agent_notes')
op.drop_table('agent_notes')
op.drop_table('system_configs')
op.drop_index('idx_sd_category', table_name='software_downloads')
op.drop_table('software_downloads')
op.drop_index('idx_qr_category', table_name='quick_reply_templates')
op.drop_table('quick_reply_templates')
op.drop_index('idx_fp_scene', table_name='funny_phrases')
op.drop_table('funny_phrases')
op.drop_index('idx_employees_employee_id', table_name='employees')
op.drop_index('idx_employees_corp_id', table_name='employees')
op.drop_table('employees')
op.drop_index('idx_conversations_urgency_score', table_name='conversations')
op.drop_index('idx_conversations_status', table_name='conversations')
op.drop_index('idx_conversations_last_message_at', table_name='conversations')
op.drop_index('idx_conversations_employee_id', table_name='conversations')
op.drop_index('idx_conversations_corp_id', table_name='conversations')
op.drop_index('idx_conversations_assigned_agent', table_name='conversations')
op.drop_table('conversations')
op.drop_index('idx_al_category', table_name='approval_links')
op.drop_table('approval_links')
op.drop_table('agents')
# ### end Alembic commands ###
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — 应用包初始化
# =============================================================================
# 说明:将 app/ 目录标记为 Python 包
# =============================================================================
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — API 包初始化
# =============================================================================
# 说明:将 api/ 目录标记为 Python 包
# =============================================================================
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
# =============================================================================
# 企微IT智能服务台 — 管理后台角色管理 API
# =============================================================================
# 说明:管理后台的角色管理接口
# 包含:
# 1. 角色管理(CRUD
# 2. 用户角色分配/撤销
# 3. 角色映射规则管理
# 所有接口需要 admin 角色权限
# =============================================================================
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, UserInfo, require_role
from app.database import get_db
from app.models.role import Role
from app.models.role_mapping_rule import RoleMappingRule
from app.models.user_role import UserRole
from app.schemas.role import (
RoleAssignRequest,
RoleMappingRuleRequest,
RoleMappingRuleResponse,
RoleRevokeRequest,
RoleResponse,
UserRoleResponse,
)
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
def _mask_sensitive_data(value: str, visible_chars: int = 3) -> str:
"""脱敏处理敏感数据。
Args:
value: 原始值
visible_chars: 开头保留的字符数
Returns:
str: 脱敏后的值,如 "abc***def"
"""
if not value:
return ""
if len(value) <= visible_chars:
return "*" * len(value)
return f"{value[:visible_chars]}{'*' * (len(value) - visible_chars)}"
# 创建路由器
router = APIRouter(prefix="/admin/roles")
# --------------------------------------------------------------------------
# 管理后台权限校验依赖
# --------------------------------------------------------------------------
async def require_admin(
current_user: UserInfo = Depends(get_current_user),
) -> UserInfo:
"""管理后台权限校验:仅 admin 角色可访问。
Args:
current_user: 当前用户(通过认证依赖注入)
Returns:
UserInfo: 具有管理权限的用户信息
Raises:
AppException: 非管理员角色(错误码 1004)
"""
if "admin" not in current_user.roles:
raise AppException(1004, "无管理权限")
return current_user
# ==========================================================================
# 1. 角色管理
# ==========================================================================
# ---------- GET /api/admin/roles ----------
@router.get("")
async def get_roles(
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取所有角色列表。
返回角色列表,包含每个角色的用户数量统计。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含角色列表
"""
# 查询所有角色
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
result = await db.execute(stmt)
roles = result.scalars().all()
# 构建响应,包含用户数量
role_list = []
for role in roles:
# 统计拥有该角色的用户数
count_stmt = select(func.count()).select_from(UserRole).where(UserRole.role_id == role.id)
count_result = await db.execute(count_stmt)
user_count = count_result.scalar() or 0
role_list.append(
RoleResponse(
id=role.id,
name=role.name,
display_name=role.display_name,
description=role.description,
permissions=role.permissions or [],
is_default=role.is_default,
user_count=user_count,
created_at=role.created_at,
updated_at=role.updated_at,
)
)
return success_response(data=[r.model_dump() for r in role_list])
# ==========================================================================
# 2. 用户角色分配/撤销
# ==========================================================================
# ---------- POST /api/admin/roles/assign ----------
@router.post("/assign")
async def assign_role(
body: RoleAssignRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""手动分配角色。
为指定用户分配角色,记录分配者和分配原因。
安全限制:禁止管理员给自己分配角色。
Args:
body: 分配角色请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 安全限制:禁止管理员给自己分配角色
if body.employee_id == admin.employee_id:
raise AppException(4014, "不能给自己分配角色")
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 检查是否已拥有该角色
existing_stmt = select(UserRole).where(
UserRole.employee_id == body.employee_id,
UserRole.role_id == role.id,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
raise AppException(4009, f"用户已拥有 {body.role_name} 角色")
# 创建用户角色关联
user_role = UserRole(
employee_id=body.employee_id,
role_id=role.id,
source="manual",
assigned_by=admin.employee_id,
)
db.add(user_role)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(body.employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
return success_response(message=f"角色 {body.role_name} 分配成功")
# ---------- POST /api/admin/roles/revoke ----------
@router.post("/revoke")
async def revoke_role(
body: RoleRevokeRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""撤销角色。
撤销指定用户的角色。
安全限制:禁止管理员撤销自己的角色。
Args:
body: 撤销角色请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 安全限制:禁止管理员撤销自己的角色
if body.employee_id == admin.employee_id:
raise AppException(4015, "不能撤销自己的角色")
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 不允许撤销默认角色
if role.is_default:
raise AppException(4010, "不能撤销默认角色")
# 查询用户角色关联
user_role_stmt = select(UserRole).where(
UserRole.employee_id == body.employee_id,
UserRole.role_id == role.id,
)
user_role_result = await db.execute(user_role_stmt)
user_role = user_role_result.scalars().first()
if not user_role:
raise AppException(4011, f"用户没有 {body.role_name} 角色")
# 删除用户角色关联
await db.delete(user_role)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 撤销用户 {_mask_sensitive_data(body.employee_id)} 的角色 {body.role_name},原因:{body.reason}")
return success_response(message=f"角色 {body.role_name} 撤销成功")
# ==========================================================================
# 3. 角色映射规则管理
# ==========================================================================
# ---------- GET /api/admin/roles/mapping-rules ----------
@router.get("/mapping-rules")
async def get_mapping_rules(
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取所有角色映射规则。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含映射规则列表
"""
# 查询所有映射规则
stmt = (
select(RoleMappingRule, Role)
.join(Role, RoleMappingRule.role_id == Role.id)
.order_by(RoleMappingRule.priority.desc(), RoleMappingRule.source_type)
)
result = await db.execute(stmt)
rules = result.all()
# 构建响应
rule_list = []
for rule, role in rules:
rule_list.append(
RoleMappingRuleResponse(
id=rule.id,
role_id=rule.role_id,
role_name=role.name,
source_type=rule.source_type,
source_value=rule.source_value,
priority=rule.priority,
is_active=rule.is_active,
created_at=rule.created_at,
)
)
return success_response(data=[r.model_dump() for r in rule_list])
# ---------- POST /api/admin/roles/mapping-rules ----------
@router.post("/mapping-rules")
async def create_mapping_rule(
body: RoleMappingRuleRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""创建角色映射规则。
Args:
body: 创建映射规则请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含新创建的规则 ID
"""
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 检查是否已存在相同的规则
existing_stmt = select(RoleMappingRule).where(
RoleMappingRule.role_id == role.id,
RoleMappingRule.source_type == body.source_type,
RoleMappingRule.source_value == body.source_value,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
raise AppException(4012, "已存在相同的映射规则")
# 创建映射规则
rule = RoleMappingRule(
role_id=role.id,
source_type=body.source_type,
source_value=body.source_value,
priority=body.priority,
is_active=body.is_active,
)
db.add(rule)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 创建映射规则:{body.source_type}={body.source_value}{body.role_name}")
return success_response(
message="映射规则创建成功",
data={"id": rule.id},
)
# ---------- DELETE /api/admin/roles/mapping-rules/{rule_id} ----------
@router.delete("/mapping-rules/{rule_id}")
async def delete_mapping_rule(
rule_id: str,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""删除角色映射规则。
Args:
rule_id: 规则 ID
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询规则
rule_stmt = select(RoleMappingRule).where(RoleMappingRule.id == rule_id)
rule_result = await db.execute(rule_stmt)
rule = rule_result.scalars().first()
if not rule:
raise AppException(4013, "映射规则不存在")
# 删除规则
await db.delete(rule)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
return success_response(message="映射规则删除成功")
+215
View File
@@ -0,0 +1,215 @@
# =============================================================================
# 企微IT智能服务台 — 坐席备注 API
# =============================================================================
# 说明:坐席端的备注管理接口,包括:
# 1. GET /api/agent-notes/{employee_id} — 获取员工的所有备注
# 2. POST /api/agent-notes — 添加备注
# 3. PUT /api/agent-notes/{id} — 更新备注
# 4. DELETE /api/agent-notes/{id} — 删除备注
# =============================================================================
import logging
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent_note import AgentNote
from app.models.conversation import Conversation
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# GET /api/agent-notes/{employee_id} — 获取员工的所有备注
# --------------------------------------------------------------------------
@router.get("/agent-notes/{employee_id}")
async def list_agent_notes(
employee_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取员工的所有备注。
通过员工ID查找其所有会话的备注。
用于坐席端用户信息面板展示。
Args:
employee_id: 员工企微 UserID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含备注列表
"""
# 查找该员工所有会话的备注
stmt = (
select(AgentNote)
.join(Conversation, AgentNote.conversation_id == Conversation.id)
.where(Conversation.employee_id == employee_id)
.order_by(AgentNote.created_at.desc())
)
result = await db.execute(stmt)
notes = list(result.scalars().all())
items = [
{
"id": str(note.id),
"conversation_id": str(note.conversation_id),
"agent_id": note.agent_id,
"content": note.content,
"created_at": note.created_at.isoformat() if note.created_at else "",
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
}
for note in notes
]
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# POST /api/agent-notes — 添加备注
# --------------------------------------------------------------------------
@router.post("/agent-notes")
async def create_agent_note(
body: dict,
db: AsyncSession = Depends(get_db),
):
"""添加坐席备注。
Args:
body: 备注请求体(包含 conversation_id, agent_id, content
db: 数据库会话
Returns:
Dict: 统一响应格式,包含创建的备注
"""
conversation_id = body.get("conversation_id", "")
agent_id = body.get("agent_id", "")
content = body.get("content", "")
if not conversation_id or not agent_id or not content:
raise AppException(1001, "缺少必要参数: conversation_id, agent_id, content")
# 校验会话存在
try:
conv_uuid = UUID(conversation_id)
except ValueError:
raise AppException(1001, "无效的 conversation_id 格式")
conv_stmt = select(Conversation).where(Conversation.id == conv_uuid)
conv_result = await db.execute(conv_stmt)
if not conv_result.scalars().first():
raise ERR_NOT_FOUND
# 创建备注
note = AgentNote(
conversation_id=conv_uuid,
agent_id=agent_id,
content=content,
)
db.add(note)
await db.flush()
logger.info(f"添加坐席备注: conv_id={conversation_id}, agent={agent_id}")
note_data = {
"id": str(note.id),
"conversation_id": str(note.conversation_id),
"agent_id": note.agent_id,
"content": note.content,
"created_at": note.created_at.isoformat() if note.created_at else "",
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
}
return success_response(data=note_data)
# --------------------------------------------------------------------------
# PUT /api/agent-notes/{id} — 更新备注
# --------------------------------------------------------------------------
@router.put("/agent-notes/{note_id}")
async def update_agent_note(
note_id: UUID,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""更新坐席备注。
Args:
note_id: 备注ID
body: 更新请求体(包含 content
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的备注
"""
# 查找备注
stmt = select(AgentNote).where(AgentNote.id == note_id)
result = await db.execute(stmt)
note = result.scalars().first()
if not note:
raise ERR_NOT_FOUND
# 更新内容
content = body.get("content")
if content is not None:
note.content = content
note.updated_at = datetime.now()
db.add(note)
await db.flush()
logger.info(f"更新坐席备注: id={note_id}")
note_data = {
"id": str(note.id),
"conversation_id": str(note.conversation_id),
"agent_id": note.agent_id,
"content": note.content,
"created_at": note.created_at.isoformat() if note.created_at else "",
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
}
return success_response(data=note_data)
# --------------------------------------------------------------------------
# DELETE /api/agent-notes/{id} — 删除备注
# --------------------------------------------------------------------------
@router.delete("/agent-notes/{note_id}")
async def delete_agent_note(
note_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""删除坐席备注。
Args:
note_id: 备注ID
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查找备注
stmt = select(AgentNote).where(AgentNote.id == note_id)
result = await db.execute(stmt)
note = result.scalars().first()
if not note:
raise ERR_NOT_FOUND
# 物理删除
await db.delete(note)
await db.flush()
logger.info(f"删除坐席备注: id={note_id}")
return success_response(data=None, message="删除成功")
+519
View File
@@ -0,0 +1,519 @@
# =============================================================================
# 企微IT智能服务台 — 坐席管理 API
# =============================================================================
# 说明:坐席端的管理接口,包括:
# 1. POST /api/agents/login — 坐席登录(用户名密码,返回JWT token)
# 2. GET /api/agents/me — 获取当前坐席信息
# 3. PUT /api/agents/me/status — 更新坐席状态(online/busy/offline
# 4. GET /api/agents — 获取坐席列表(用于转接选择)
# 坐席认证使用 JWTtoken 存 RedisTTL 8小时)
# =============================================================================
import base64
import io
import json
import logging
import secrets
from datetime import datetime
from typing import Optional
from uuid import UUID
import pyotp
import qrcode
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Query, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user, require_role
from app.models.agent import Agent
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
from app.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
# 速率限制器实例(与 main.py 共享同一配置)
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
# python-dotenv 已在应用启动时处理 .env 文件
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# JWT 简化版:使用随机 token 存 RedisTTL 8 小时
# 为什么不用标准 JWT:第一步简化实现,token 存 Redis 更容易实现登出和状态管理
TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时
def _get_redis() -> aioredis.Redis:
"""获取 Redis 客户端。"""
return settings.create_redis_client()
# --------------------------------------------------------------------------
# 坐席认证依赖
# --------------------------------------------------------------------------
async def get_current_agent(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
) -> Agent:
"""从请求头中提取坐席身份(认证依赖)。
支持两种 Token 格式:
1. 统一格式:user:token:{token} → JSON 包含 employee_id 和 roles
2. 旧格式:agent:token:{token} → 直接存储 user_id
Args:
authorization: 请求头中的 Authorization 字段(格式:Bearer token
db: 数据库会话
Returns:
Agent: 当前坐席对象
Raises:
AppException: 未授权(token 缺失、无效或过期)
"""
if not authorization:
raise ERR_UNAUTHORIZED
# 提取 token(支持 "Bearer xxx" 格式)
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
if not token:
raise ERR_UNAUTHORIZED
# 从 Redis 查找坐席ID
redis_client = _get_redis()
try:
# 1. 尝试统一格式(新)
unified_data = await redis_client.get(f"user:token:{token}")
if unified_data:
try:
user_info = json.loads(unified_data)
agent_user_id = user_info.get("employee_id")
if agent_user_id:
# 从数据库查找坐席
stmt = select(Agent).where(Agent.user_id == agent_user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if agent:
return agent
except json.JSONDecodeError:
logger.warning(f"统一 Token 数据解析失败: {token[:10]}...")
# 2. 尝试旧格式(兼容)
agent_user_id = await redis_client.get(f"agent:token:{token}")
if not agent_user_id:
raise ERR_UNAUTHORIZED
# 从数据库查找坐席
# agent_user_id 可能是 bytesRedis 返回)或 str
uid = agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
stmt = select(Agent).where(Agent.user_id == uid)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
raise ERR_UNAUTHORIZED
return agent
except AppException:
# 业务异常直接抛出(如 ERR_UNAUTHORIZED
raise
except Exception as e:
# Redis 连接失败等底层异常
logger.error(f"Redis 读取失败: {e}")
raise ERR_UNAUTHORIZED
finally:
try:
await redis_client.close()
except Exception:
pass
# --------------------------------------------------------------------------
# POST /api/agents/login — 坐席登录
# --------------------------------------------------------------------------
@router.post("/agents/login")
@limiter.limit("10/minute") # 登录接口限流:每IP每分钟最多10次,防暴力破解
async def agent_login(
request: Request,
body: AgentLogin,
db: AsyncSession = Depends(get_db),
):
"""坐席登录。
第一步使用简单的用户名密码登录。
登录成功后生成 token 存入 RedisTTL 8小时)。
流程:
1. 查找坐席记录(按 user_id),不存在则自动创建
2. 生成随机 token
3. token 存 Rediskey: agent:token:{token}, value: user_id
4. 更新坐席状态为 online
5. 返回坐席信息和 token
Args:
body: 登录请求体(包含 user_id 和 name
db: 数据库会话
Returns:
Dict: 统一响应格式,包含坐席信息和 token
"""
try:
# 0. 企微通讯录身份验证(防止任意 user_id 冒充坐席)
# 调用企微API校验 user_id 是否存在于通讯录中
# 安全策略:
# - 企微验证通过 → 正常登录,用企微真实姓名覆盖前端传入值
# - 企微验证失败(用户不存在) → 拒绝登录
# - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证
wecom_verified = False
try:
redis_client_verify = _get_redis()
try:
wecom_service = WecomService(redis_client_verify)
user_info = await wecom_service.get_user_info(body.user_id)
# 验证通过:用户存在于企微通讯录
wecom_verified = True
# 用企微返回的真实姓名覆盖前端传入的姓名(防止冒用他人身份)
real_name = user_info.get("name", "")
if real_name:
body.name = real_name
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
finally:
try:
await redis_client_verify.close()
except Exception:
pass
try:
await wecom_service.close()
except Exception:
pass
except Exception as wecom_err:
# 企微API不可达时:仅允许已注册坐席降级登录,新注册必须验证
# 原因:网络故障不应阻断已注册坐席工作,但不能让未验证用户注册新账号
logger.warning(
f"企微通讯录验证失败: user_id={body.user_id}, "
f"error={wecom_err}"
)
# 检查是否为已注册坐席(数据库已有记录才允许降级登录)
check_stmt = select(Agent).where(Agent.user_id == body.user_id)
check_result = await db.execute(check_stmt)
existing_agent = check_result.scalars().first()
if not existing_agent:
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充
raise AppException(
1003,
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
)
logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
)
# 1. 查找或创建坐席记录
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
# 首次登录,创建坐席记录
agent = Agent(
user_id=body.user_id,
name=body.name,
status="online",
current_load=0,
max_load=5,
)
db.add(agent)
await db.flush()
logger.info(f"新坐席注册: user_id={body.user_id}, name={body.name}")
else:
# 更新坐席名称(可能改名了)
agent.name = body.name
agent.status = "online"
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. OTP 二次验证(admin 角色且已绑定 OTP)
if agent.role == "admin" and agent.otp_enabled == 1:
if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记
return success_response(data={
"require_otp": True,
"message": "请输入OTP动态码",
"user_id": agent.user_id,
"name": agent.name,
})
else:
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误,请重新输入")
# 3. 生成随机 token(使用统一格式)
from app.services.token_service import TokenService
from app.dependencies import get_redis
# 使用共享 Redis 连接(从连接池获取,不要手动关闭)
redis_client = await get_redis()
token_service = TokenService(redis_client)
# 查询用户角色
from app.services.role_mapping_service import RoleMappingService
role_service = RoleMappingService(db)
roles = await role_service.get_user_roles(body.user_id)
# 创建统一格式的 Token
token = await token_service.create_token(
employee_id=body.user_id,
name=body.name,
roles=roles,
login_source="agent",
)
# 5. 返回坐席信息和 token
agent_data = AgentResponse.model_validate(agent).model_dump()
agent_data["token"] = token
return success_response(data=agent_data)
except AppException:
# 业务异常直接抛出
raise
except Exception as e:
# 未预期的异常:记录日志,返回友好错误
logger.error(f"登录异常: {e}", exc_info=True)
raise AppException(1005, f"登录失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /api/agents/me — 获取当前坐席信息
# --------------------------------------------------------------------------
@router.get("/agents/me")
async def get_agent_me(
agent: Agent = Depends(get_current_agent),
):
"""获取当前坐席信息。
需要在请求头中携带有效的 token。
Args:
agent: 当前坐席(通过认证依赖注入)
Returns:
Dict: 统一响应格式,包含坐席信息
"""
agent_data = AgentResponse.model_validate(agent).model_dump()
return success_response(data=agent_data)
# --------------------------------------------------------------------------
# PUT /api/agents/me/status — 更新坐席状态
# --------------------------------------------------------------------------
@router.put("/agents/me/status")
async def update_agent_status(
body: AgentStatusUpdate,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""更新坐席状态。
坐席可以切换为 online/busy/offline。
- online: 在线,可以接收新会话
- busy: 忙碌,不接收新会话但继续处理已有的
- offline: 离线,不接收任何会话
Args:
body: 状态更新请求体
agent: 当前坐席
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的坐席信息
"""
agent.status = body.status
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"坐席状态更新: agent={agent.user_id}, status={body.status}")
agent_data = AgentResponse.model_validate(agent).model_dump()
return success_response(data=agent_data)
# --------------------------------------------------------------------------
# GET /api/agents — 获取坐席列表(需要 agent 或 admin 角色)
# --------------------------------------------------------------------------
@router.get("/agents")
@require_role("agent", "admin")
async def list_agents(
status: Optional[str] = Query(None, description="按状态过滤: online/busy/offline"),
db: AsyncSession = Depends(get_db),
):
"""获取坐席列表。
用于转接选择时展示可用的坐席列表。
Args:
status: 按状态过滤(可选)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含坐席列表
"""
stmt = select(Agent).order_by(Agent.name)
if status:
stmt = stmt.where(Agent.status == status)
result = await db.execute(stmt)
agents = list(result.scalars().all())
items = [AgentResponse.model_validate(a).model_dump() for a in agents]
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# OTP 绑定接口
# --------------------------------------------------------------------------
@router.post("/agents/otp-bind")
async def bind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""为当前坐席生成 OTP 密钥和二维码。
生成 TOTP 密钥,生成 otpauth:// URI 用于扫码绑定 Google Authenticator。
返回二维码(base64编码)和密钥,供用户手动输入备用。
Returns:
Dict: 二维码图片(base64)和密钥
"""
try:
# 检查是否已绑定
if agent.otp_secret:
# 已绑定,返回现有密钥的二维码
totp = pyotp.TOTP(agent.otp_secret)
else:
# 生成新密钥
secret = pyotp.random_base32()
agent.otp_secret = secret
# otp_enabled 保持 0,等待首次验证后启用
db.add(agent)
await db.flush()
totp = pyotp.TOTP(secret)
# 生成 otpauth:// URI
otpauth_uri = totp.provisioning_uri(
name=f"IT支持服务:{agent.name}",
issuer_name="IT支持服务",
)
# 生成二维码图片
qr = qrcode.make(otpauth_uri)
buffer = io.BytesIO()
qr.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...")
return success_response(data={
"qr_code": f"data:image/png;base64,{qr_base64}",
"secret": agent.otp_secret,
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP绑定异常: {e}", exc_info=True)
raise AppException(1007, f"OTP绑定失败: {str(e)}")
@router.post("/agents/otp-verify")
async def verify_agent_otp(
body: AgentLogin, # 复用 AgentLoginotp_code 为必填
db: AsyncSession = Depends(get_db),
):
"""验证并启用 OTP。
用户输入 OTP 码验证成功后,启用 OTP。
首次验证成功后 otp_enabled 设为 1。
Args:
body.otp_code: 用户输入的 OTP 码(必填)
Returns:
Dict: 验证结果
"""
try:
# 查找坐席
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent or not agent.otp_secret:
raise AppException(1008, "请先绑定OTP")
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误")
# 验证成功,启用 OTP
agent.otp_enabled = 1
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
return success_response(data={
"otp_enabled": True,
"message": "OTP验证成功,已启用",
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP验证异常: {e}", exc_info=True)
raise AppException(1009, f"OTP验证失败: {str(e)}")
@router.post("/agents/otp-unbind")
async def unbind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""解绑 OTP。
解绑后 otp_secret 和 otp_enabled 都清空。
需要管理员操作。
Returns:
Dict: 解绑结果
"""
try:
agent.otp_secret = None
agent.otp_enabled = 0
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP解绑: agent={agent.user_id}")
return success_response(data={"message": "OTP已解绑"})
except AppException:
raise
except Exception as e:
logger.error(f"OTP解绑异常: {e}", exc_info=True)
raise AppException(1010, f"OTP解绑失败: {str(e)}")
+688
View File
@@ -0,0 +1,688 @@
# =============================================================================
# 企微IT智能服务台 — 会话管理 API
# =============================================================================
# 说明:坐席端的会话管理接口,包括:
# 1. GET /api/conversations — 坐席获取会话列表(支持状态过滤、排序)
# 2. GET /api/conversations/{id} — 获取会话详情
# 3. POST /api/conversations/{id}/assign — 接单(坐席接入会话)
# 4. POST /api/conversations/{id}/resolve — 结单
# 5. POST /api/conversations/{id}/pin — 置顶/取消置顶
# 6. POST /api/conversations/{id}/todo — 代办/取消代办
# 7. POST /api/conversations/{id}/transfer — 转接
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.schemas.conversation import (
ConversationAssign,
ConversationInvite,
ConversationListResponse,
ConversationResponse,
ConversationStatusUpdate,
InviteParticipantRequest,
JoinConversationRequest,
)
from app.services.session_service import SessionService
from app.services.wecom_service import WecomService
from app.utils.response import AppException, success_response
# 坐席认证依赖(从 agents.py 导入)
from app.api.agents import get_current_agent
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# GET /api/conversations — 获取坐席会话列表(全局可见)
# --------------------------------------------------------------------------
@router.get("/conversations")
async def list_conversations(
status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"),
agent_id: Optional[str] = Query(None, description="按坐席ID过滤"),
page: int = Query(1, ge=1, description="页码(从1开始)"),
page_size: int = Query(50, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席获取会话列表(全局可见)。
返回所有活跃会话,每个会话增加字段:
- is_mine: 是否为当前坐席的会话
- assigned_agent_name: 分配的坐席姓名(其他坐席会话显示用)
- can_grab: 是否可以接手(其他坐席已接单的会话为 True)
排序规则:紧急→举手→需介入→活跃→已结单。
Args:
status: 按状态过滤(可选)
agent_id: 按坐席ID过滤(可选)
page: 页码
page_size: 每页数量
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含会话列表和总数
"""
session_service = SessionService(db)
conversations, total = await session_service.get_conversations(
status=status,
agent_id=agent_id,
page=page,
page_size=page_size,
)
# 批量查询所有涉及坐席的信息,避免 N+1 查询
# 收集所有需要查询姓名的坐席ID(主责坐席 + 协作坐席)
agent_ids_to_query = set()
for conv in conversations:
if conv.assigned_agent_id:
agent_ids_to_query.add(conv.assigned_agent_id)
for aid in (conv.collaborating_agent_ids or []):
agent_ids_to_query.add(aid)
# 一次性查询所有相关坐席姓名
agent_name_map: dict[str, str] = {}
if agent_ids_to_query:
stmt = select(Agent).where(Agent.user_id.in_(agent_ids_to_query))
result = await db.execute(stmt)
for agent in result.scalars().all():
agent_name_map[agent.user_id] = agent.name
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段
items = []
for conv in conversations:
conv_data = ConversationResponse.model_validate(conv).model_dump()
# 是否为当前坐席的会话
conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id
# 坐席姓名(从批量查询结果中获取)
conv_data["assigned_agent_name"] = agent_name_map.get(conv.assigned_agent_id) if conv.assigned_agent_id else None
# 是否可以接手:其他坐席已接单(assigned 且不是自己的)
conv_data["can_grab"] = (
conv.assigned_agent_id is not None
and conv.assigned_agent_id != current_agent.user_id
and conv.status == "serving"
)
# ----- 多坐席协作扩展字段 -----
# 协作坐席ID列表
collab_ids = conv.collaborating_agent_ids or []
conv_data["collaborating_agent_ids"] = collab_ids
# 协作坐席姓名映射
conv_data["collaborating_agent_names"] = {
aid: agent_name_map.get(aid, "未知") for aid in collab_ids
}
# 是否为协作坐席(在协作列表中但不是主责坐席)
conv_data["is_collaborator"] = (
current_agent.user_id in collab_ids
and conv.assigned_agent_id != current_agent.user_id
)
items.append(conv_data)
return success_response(
data={
"items": items,
"total": total,
}
)
# --------------------------------------------------------------------------
# GET /api/conversations/{id} — 获取会话详情
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}")
async def get_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取会话详情。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含会话详情
"""
session_service = SessionService(db)
conversation = await session_service.get_conversation(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/assign — 坐席接单
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/assign")
async def assign_conversation(
conversation_id: str,
body: ConversationAssign,
db: AsyncSession = Depends(get_db),
):
"""坐席接单(接入会话)。
坐席点击"接单"按钮时调用,将会话状态从 queued 改为 serving。
Args:
conversation_id: 会话ID
body: 接单请求体(包含 agent_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
# 创建企微服务实例用于发送接入通知
redis_client = None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
session_service = SessionService(db, wecom_service=wecom_service)
except Exception:
logger.warning("创建企微服务失败,接入通知将不发送")
session_service = SessionService(db)
conversation = await session_service.assign_agent(
conversation_id=conversation_id,
agent_id=body.agent_id,
)
# 关闭企微服务连接
if redis_client:
try:
await session_service.wecom_service.close()
await redis_client.close()
except Exception:
pass
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/resolve — 结单
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/resolve")
async def resolve_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""结单。
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
权限控制:只有主责坐席(assigned_agent_id)才能结单。
协作坐席和其他坐席不能结单。
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
# 先查询会话,验证主责坐席身份
from sqlalchemy import select as _select
from app.models.conversation import Conversation as _Conversation
stmt = _select(_Conversation).where(_Conversation.id == conversation_id)
result = await db.execute(stmt)
conv = result.scalars().first()
if not conv:
raise AppException(3003, "会话不存在")
if conv.assigned_agent_id != current_agent.user_id:
raise AppException(3027, "只有主责坐席才能结单")
conversation = await session_service.resolve_conversation(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/pin — 置顶/取消置顶
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/pin")
async def toggle_pin(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""切换会话置顶状态。
每次调用切换当前状态:置顶→取消置顶,取消置顶→置顶。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.toggle_pin(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/todo — 代办/取消代办
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/todo")
async def toggle_todo(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""切换会话代办状态。
每次调用切换当前状态:代办→取消代办,取消代办→代办。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.toggle_todo(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/transfer — 转接
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/transfer")
async def transfer_conversation(
conversation_id: str,
body: ConversationAssign,
db: AsyncSession = Depends(get_db),
):
"""转接会话到另一个坐席。
第一步简化版:只更换坐席,不做转接通知。
Args:
conversation_id: 会话ID
body: 转接请求体(包含 target agent_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.transfer_conversation(
conversation_id=conversation_id,
target_agent_id=body.agent_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/grab — 接手会话(抢单)
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/grab")
async def grab_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""接手其他坐席的会话(抢单)。
接手后原坐席自动释放,会话 assigned_agent_id 切换为当前坐席。
验证规则:
1. 会话必须已分配给其他坐席(不能接手自己的,不能接手未分配的)
2. 当前坐席未满负荷
3. 会话状态为 serving
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含接手后的会话信息
"""
# 1. 查找目标会话
session_service = SessionService(db)
conversation = await session_service.get_conversation(conversation_id)
# 2. 校验:会话必须已分配给其他坐席
if not conversation.assigned_agent_id:
raise AppException(3011, "该会话尚未分配坐席,请使用接单功能")
if conversation.assigned_agent_id == current_agent.user_id:
raise AppException(3012, "不能接手自己的会话")
if conversation.status == "resolved":
raise AppException(3002, "会话已结单")
if conversation.status != "serving":
raise AppException(3013, f"只能接手服务中的会话,当前状态: {conversation.status}")
# 3. 校验当前坐席未满负荷
# 刷新坐席数据(current_agent 可能是缓存的旧数据)
stmt = select(Agent).where(Agent.user_id == current_agent.user_id)
result = await db.execute(stmt)
fresh_agent = result.scalars().first()
if fresh_agent and fresh_agent.current_load >= fresh_agent.max_load:
raise AppException(3005, "您已满负荷,无法接手更多会话")
# 4. 原坐席 current_load 减 1
old_agent_id = conversation.assigned_agent_id
stmt = select(Agent).where(Agent.user_id == old_agent_id)
result = await db.execute(stmt)
old_agent = result.scalars().first()
if old_agent and old_agent.current_load > 0:
old_agent.current_load -= 1
db.add(old_agent)
# 5. 更新会话 assigned_agent_id 为当前坐席
conversation.assigned_agent_id = current_agent.user_id
conversation.updated_at = datetime.now()
db.add(conversation)
# 6. 当前坐席 current_load 加 1
if fresh_agent:
fresh_agent.current_load += 1
db.add(fresh_agent)
await db.flush()
logger.info(
f"会话接手: conv_id={conversation_id}, "
f"from={old_agent_id} to={current_agent.user_id}"
)
# 7. WS 广播 conversation_updated 事件(原坐席和当前坐席都能收到)
from app.services.ws_manager import manager as ws_manager
try:
await ws_manager.broadcast({
"type": "conversation_updated",
"data": {
"conversation_id": str(conversation.id),
"status": conversation.status,
"assigned_agent_id": conversation.assigned_agent_id,
"old_agent_id": old_agent_id,
"new_agent_id": current_agent.user_id,
}
})
except Exception as e:
logger.warning(f"WebSocket广播失败: {e}")
# 8. 返回接手成功的会话信息
response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["is_mine"] = True
response_data["assigned_agent_name"] = current_agent.name
response_data["can_grab"] = False
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/invite — 摇人(邀请坐席协作)
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/invite")
async def invite_collaborator(
conversation_id: str,
body: ConversationInvite,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席A邀请坐席B加入会话协作。
校验规则:
1. 当前坐席必须是主责坐席或已加入的协作坐席
2. 被邀请坐席存在且在线
3. 被邀请坐席不是主责坐席,也不在协作列表中(防止重复邀请)
4. 会话必须为 serving(已结单的不能摇人)
副作用:
- WebSocket 推送给被邀请坐席(collaborator_invited 定向通知)
- WebSocket 广播给所有坐席(collaborator_joined 刷新列表)
Args:
conversation_id: 会话ID
body: 邀请请求(含 agent_id
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.invite_collaborator(
conversation_id=conversation_id,
inviter_agent_id=current_agent.user_id,
invitee_agent_id=body.agent_id,
)
# 构建响应
response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["is_mine"] = conversation.assigned_agent_id == current_agent.user_id
response_data["is_collaborator"] = False # 邀请人自己不是被邀请的协作坐席
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/leave — 退出协作
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/leave")
async def leave_collaboration(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席退出协作。
校验规则:
1. 当前坐席必须在协作列表中
2. 当前坐席不能是主责坐席(主责坐席不能"退出",只能转接或结单)
副作用:
- WebSocket 广播给所有坐席(collaborator_left 刷新列表)
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.leave_collaboration(
conversation_id=conversation_id,
agent_id=current_agent.user_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# =============================================================================
# 邀请功能 APIP0-09~P0-11
# =============================================================================
# 和「摇人」的区别:
# 摇人 (invite) = 坐席 → 坐席协作(collaborating_agent_ids
# 邀请 (invite-participant) = 坐席 → 任意员工/部门(participants
# =============================================================================
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/invite-participant")
async def invite_participant(
conversation_id: str,
body: InviteParticipantRequest,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席邀请员工/部门加入会话(P0-09 邀请发起)。
权限:只有主责坐席可以发起邀请。
副作用:
- 向被邀请人发送企微卡片通知(含「加入会话」按钮)
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 邀请请求(含被邀请人列表 + 历史共享模式)
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
# 创建企微服务实例(发送卡片通知用)
redis_client = None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
session_service = SessionService(db, wecom_service=wecom_service)
except Exception:
logger.warning("创建企微服务失败,邀请通知将不发送")
session_service = SessionService(db)
conversation = await session_service.invite_participants(
conversation_id=conversation_id,
inviter_agent_id=current_agent.user_id,
participants=[p.model_dump() for p in body.participants],
history_mode=body.history_mode,
)
# 关闭连接
if redis_client:
try:
await session_service.wecom_service.close()
await redis_client.close()
except Exception:
pass
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/join — 被邀请人加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/join")
async def join_conversation(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""被邀请人通过链接加入会话(P0-10 加入会话)。
校验:该员工必须在 participants 列表中(被邀请过才能加入)。
副作用:
- 更新参与者的 joined 状态
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 加入请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.join_conversation(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者
# --------------------------------------------------------------------------
@router.delete("/conversations/{conversation_id}/participants/{user_id}")
async def remove_participant(
conversation_id: str,
user_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""移除参与者(P0-11 参与者管理)。
权限:只有主责坐席可以移除参与者。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
user_id: 被移除的员工UserID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.remove_participant(
conversation_id=conversation_id,
remover_agent_id=current_agent.user_id,
target_user_id=user_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/leave-participant — 参与者主动退出
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/leave-participant")
async def leave_as_participant(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""参与者主动退出会话。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 退出请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.leave_as_participant(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
+116
View File
@@ -0,0 +1,116 @@
# =============================================================================
# 企微IT智能服务台 — 员工 API
# =============================================================================
# 说明:提供员工相关的管理接口
# 接口列表:
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
# =============================================================================
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, field_validator
from app.utils.response import success_response
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
# 创建路由器
router = APIRouter(prefix="/employees", tags=["员工管理"])
# --------------------------------------------------------------------------
# 请求 Schema
# --------------------------------------------------------------------------
class ItLevelUpdateRequest(BaseModel):
"""IT技能等级更新请求 Schema。"""
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
@field_validator("it_level")
@classmethod
def validate_it_level(cls, v: str) -> str:
"""校验IT等级值是否合法。"""
if v not in VALID_IT_LEVELS:
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
return v
@field_validator("source")
@classmethod
def validate_source(cls, v: str) -> str:
"""校验等级来源值是否合法。"""
if v not in VALID_LEVEL_SOURCES:
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
return v
class ItLevelUpdateResponse(BaseModel):
"""IT技能等级更新响应 Schema。"""
employee_id: str
it_level: str
it_level_source: str
message: str
# --------------------------------------------------------------------------
# Mock 员工数据存储(IT 等级映射)
# --------------------------------------------------------------------------
# 简单的内存存储,key 为 employee_idvalue 为 it_level
MOCK_EMPLOYEE_IT_LEVELS: dict = {
"emp-001": "silver",
"emp-002": "gold",
"emp-003": "bronze",
"emp-004": "platinum",
"emp-005": "diamond",
"emp-006": "silver",
"emp-007": "star",
"emp-008": "king",
}
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.put("/{employee_id}/it-level")
async def update_employee_it_level(
employee_id: str,
request: ItLevelUpdateRequest,
):
"""更新员工IT技能等级。
坐席可以手动调整员工的IT技能等级,等级来源标记为 manual。
更新后等级立即生效,并记录来源以便追溯。
Args:
employee_id: 员工ID
request: 等级更新请求
Returns:
更新结果
"""
# 更新内存中的等级
old_level = MOCK_EMPLOYEE_IT_LEVELS.get(employee_id, "silver")
MOCK_EMPLOYEE_IT_LEVELS[employee_id] = request.it_level
# 构造等级名称映射
level_names = {
"bronze": "青铜",
"silver": "白银",
"gold": "黄金",
"platinum": "铂金",
"diamond": "钻石",
"star": "星耀",
"king": "王者",
}
return success_response(data=ItLevelUpdateResponse(
employee_id=employee_id,
it_level=request.it_level,
it_level_source=request.source,
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
).model_dump())
File diff suppressed because it is too large Load Diff
+556
View File
@@ -0,0 +1,556 @@
# =============================================================================
# 企微IT智能服务台 — 消息管理 API
# =============================================================================
# 说明:坐席端的消息管理接口,包括:
# 1. GET /api/conversations/{id}/messages — 获取会话消息列表(分页)
# 2. POST /api/conversations/{id}/messages — 坐席发送消息
# 3. GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
# 4. POST /api/messages/{id}/recall — 撤回消息(2分钟内)
# 5. DELETE /api/messages/{id} — 删除消息
# 6. POST /api/conversations/{id}/mark-read — 标记已读
# 7. POST /api/messages/image — 上传图片
# 8. POST /api/messages/file — 上传文件
# 消息发送需同时:存数据库 + 调用企微API发送给员工
# =============================================================================
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.models.message import Message
from app.schemas.message import MessageCreate, MessageResponse
from app.api.agents import get_current_agent
from app.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# 文件大小限制:10MB
MAX_FILE_SIZE = 10 * 1024 * 1024
# 可撤回时间窗口:2分钟
RECALLABLE_WINDOW_MINUTES = 2
# --------------------------------------------------------------------------
# GET /api/conversations/{id}/messages — 获取会话消息列表
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages")
async def list_messages(
conversation_id: str,
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
before: Optional[str] = Query(None, description="加载此消息ID之前的消息(向上翻页)"),
db: AsyncSession = Depends(get_db),
):
"""获取会话消息列表(分页)。
支持向上加载历史消息(通过 before 参数指定消息ID)。
默认返回最新的 limit 条消息。
Args:
conversation_id: 会话ID
limit: 每页消息数量
before: 加载此消息ID之前的消息(向上翻页)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含消息列表和是否还有更多消息
"""
# 校验会话存在(UUID 转为字符串,兼容 SQLite String(36) 列)
conv_id_str = str(conversation_id)
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
conv_result = await db.execute(conv_stmt)
conversation = conv_result.scalars().first()
if not conversation:
raise ERR_CONVERSATION_NOT_FOUND
# 构建查询
stmt = select(Message).where(
Message.conversation_id == conv_id_str
).order_by(Message.created_at.desc())
# 如果指定了 before,只加载该消息之前的消息
if before:
try:
before_uuid = str(UUID(before))
# 先获取 before 消息的创建时间
before_stmt = select(Message.created_at).where(Message.id == before_uuid)
before_result = await db.execute(before_stmt)
before_time = before_result.scalar_one_or_none()
if before_time:
stmt = stmt.where(Message.created_at < before_time)
except ValueError:
pass # before 参数格式错误,忽略
# 限制数量
stmt = stmt.limit(limit + 1) # 多查一条判断是否还有更多
result = await db.execute(stmt)
messages = list(result.scalars().all())
# 判断是否还有更多消息
has_more = len(messages) > limit
if has_more:
messages = messages[:limit] # 去掉多查的那一条
# 按时间正序排列(最早的在前)
messages.reverse()
# 标记消息为已读(坐席查看时自动标记)
for msg in messages:
if not msg.is_read and msg.sender_type == "employee":
msg.is_read = True
await db.flush()
# 转换为响应格式
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
return success_response(
data={
"items": items,
"has_more": has_more,
}
)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/messages — 坐席发送消息
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/messages")
async def send_message(
conversation_id: str,
body: MessageCreate,
db: AsyncSession = Depends(get_db),
):
"""坐席发送消息。
流程:
1. 校验会话存在且未结单
2. 将消息存入 messages 表
3. 调用企微 API 发送消息给员工
4. 更新会话的最后消息信息
Args:
conversation_id: 会话ID
body: 消息请求体(包含 content 和 msg_type
db: 数据库会话
Returns:
Dict: 统一响应格式,包含发送的消息对象
"""
# 1. 校验会话(UUID 转为字符串,兼容 SQLite String(36) 列)
conv_id_str = str(conversation_id)
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
conv_result = await db.execute(conv_stmt)
conversation = conv_result.scalars().first()
if not conversation:
raise ERR_CONVERSATION_NOT_FOUND
if conversation.status == "resolved":
raise ERR_CONVERSATION_RESOLVED
# 2. 创建消息记录
# 从会话的 assigned_agent_id 获取坐席信息
agent_id = conversation.assigned_agent_id or "unknown"
# 计算可撤回截止时间
recallable_until = datetime.now() + timedelta(minutes=RECALLABLE_WINDOW_MINUTES)
message = Message(
conversation_id=conv_id_str,
sender_type="agent",
sender_id=agent_id,
sender_name="", # 坐席姓名,后续从坐席信息补充
content=body.content,
msg_type=body.msg_type,
# M1 新增:文件上传相关字段
media_url=body.media_url,
file_name=body.file_name,
file_size=body.file_size,
# M1 新增:引用回复
reply_to_id=body.reply_to_id,
status="sending", # 初始状态为发送中
recallable_until=recallable_until,
is_read=True, # 坐席自己发的消息默认已读
)
db.add(message)
# 3. 更新会话最后消息信息
conversation.last_message_at = datetime.now()
conversation.last_message_summary = body.content[:256]
conversation.updated_at = datetime.now()
db.add(conversation)
await db.flush() # 刷新以获取消息 ID
# 4. 调用企微 API 发送消息给员工
# 注意:只有 text 类型消息才需要调用企微 API 推送给员工
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
# 跳过 Redis 连可避免无谓的网络开销,减少截图发送超时
if body.msg_type == "text":
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
await wecom_service.send_text_message(
conversation.employee_id, body.content
)
await wecom_service.close()
await redis_client.close()
except Exception as e:
# 企微 API 调用失败不阻塞消息存储
logger.warning(f"企微消息发送失败(消息已存储): {e}")
# 5. 更新消息状态为已发送
message.status = "sent"
await db.flush()
# 转换为响应格式
response_data = MessageResponse.model_validate(message).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages/poll")
async def poll_messages(
conversation_id: str,
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
db: AsyncSession = Depends(get_db),
):
"""坐席轮询新消息。
前端每 3-5 秒调用一次,获取上次轮询后的新消息。
Args:
conversation_id: 会话ID
after_message_id: 上次轮询的最后一消息ID(返回此之后的消息)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含新消息列表
"""
# 构建查询(UUID 转为字符串,兼容 SQLite String(36) 列)
conv_id_str = str(conversation_id)
stmt = select(Message).where(
Message.conversation_id == conv_id_str
).order_by(Message.created_at.asc())
# 如果指定了 after_message_id,只返回该ID之后的消息
if after_message_id:
try:
# 获取 after_message 的创建时间
# 注意:确保用字符串比较,避免SQLAlchemy把参数转成UUID导致类型不匹配
after_stmt = select(Message.created_at).where(
Message.id == str(after_message_id)
)
after_result = await db.execute(after_stmt)
after_time = after_result.scalar_one_or_none()
if after_time:
stmt = stmt.where(Message.created_at > after_time)
except Exception:
pass # 参数格式错误或查询失败,忽略
result = await db.execute(stmt)
messages = list(result.scalars().all())
# 标记员工消息为已读
for msg in messages:
if not msg.is_read and msg.sender_type == "employee":
msg.is_read = True
await db.flush()
# 转换为响应格式
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
return success_response(
data={
"items": items,
"has_more": False, # 轮询接口不需要分页
}
)
# --------------------------------------------------------------------------
# POST /api/messages/{id}/recall — 撤回消息(2分钟内)
# --------------------------------------------------------------------------
@router.post("/messages/{message_id}/recall")
async def recall_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""撤回消息(2分钟内)。
仅可撤回2分钟内坐席自己发送的消息。
P0-2 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可调用此端点修改任意消息。
现在依赖 get_current_agent 校验登录态,再校验 message.sender_id
是否等于当前坐席的 user_id,防止越权撤回他人消息。
Args:
message_id: 消息ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询消息
stmt = select(Message).where(Message.id == str(message_id))
result = await db.execute(stmt)
message = result.scalars().first()
if not message:
raise AppException(code=404, message="消息不存在")
# 校验是否是坐席发送的消息
if message.sender_type != "agent":
raise AppException(code=403, message="只能撤回坐席发送的消息")
# P0-2 修复:校验是否是当前坐席自己发的
if message.sender_id != agent.user_id:
raise AppException(code=403, message="只能撤回自己的消息")
# 校验是否在可撤回时间窗口内
if message.recallable_until and datetime.now() > message.recallable_until:
raise AppException(code=403, message="消息已超过2分钟,无法撤回")
# 将消息内容置为空,表示已撤回
message.content = "[消息已撤回]"
message.status = "recalled"
await db.flush()
return success_response(message="消息撤回成功")
# --------------------------------------------------------------------------
# DELETE /api/messages/{id} — 删除消息
# --------------------------------------------------------------------------
@router.delete("/messages/{message_id}")
async def delete_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""删除坐席自己发送的消息。
P0-3 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可删除任意消息。
现在依赖 get_current_agent 校验登录态,再校验消息是否属于当前坐席,
防止越权删除他人/会话历史。
Args:
message_id: 消息ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询消息
stmt = select(Message).where(Message.id == str(message_id))
result = await db.execute(stmt)
message = result.scalars().first()
if not message:
raise AppException(code=404, message="消息不存在")
# P0-3 修复:仅允许坐席删除自己发送的消息
if message.sender_type != "agent" or message.sender_id != agent.user_id:
raise AppException(code=403, message="只能删除自己发送的消息")
# 删除消息
await db.delete(message)
await db.flush()
return success_response(message="消息删除成功")
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/mark-read — 标记已读
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/mark-read")
async def mark_read(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""标记会话中所有员工未读消息为已读。
P0-4 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可标记任意会话为已读,
会破坏"未读消息数"业务统计。
现在依赖 get_current_agent 校验登录态,再校验当前坐席是会话的
主责或协作坐席才允许标记,防止越权篡改未读状态。
P2-3 修复:原 `.where(Message.is_read == False)` 是 Python 表达式比较
永远为 False(不抛错但实际未过滤),SQLAlchemy 也会当成赋值表达式
处理;改为 `is_(False)` 走 SQL 否定。
Args:
conversation_id: 会话ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
conv_id_str = str(conversation_id)
# P0-4 修复:先校验当前坐席有权访问此会话
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
conv_result = await db.execute(conv_stmt)
conversation = conv_result.scalars().first()
if not conversation:
raise ERR_CONVERSATION_NOT_FOUND
is_assigned = conversation.assigned_agent_id == agent.user_id
is_collaborator = agent.user_id in (conversation.collaborating_agent_ids or [])
if not (is_assigned or is_collaborator):
raise AppException(code=403, message="您不是该会话的坐席,无权操作")
# P2-3 修复:使用 is_(False) 而非 == False
# 更新该会话的所有员工未读消息为已读
stmt = (
update(Message)
.where(Message.conversation_id == conv_id_str)
.where(Message.sender_type == "employee")
.where(Message.is_read.is_(False))
.values(is_read=True, status="read")
)
await db.execute(stmt)
await db.flush()
return success_response(message="标记已读成功")
# --------------------------------------------------------------------------
# POST /api/messages/image — 上传图片
# --------------------------------------------------------------------------
@router.post("/messages/image")
async def upload_image(
file: UploadFile = File(...),
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""上传图片文件。
文件大小限制:10MB
Args:
file: 图片文件
db: 数据库会话
Returns:
Dict: 统一响应格式,包含文件URL和元数据
"""
# 校验文件大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise AppException(code=400, message=f"文件大小超过10MB限制")
# 校验文件类型
allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
content_type = file.content_type
if content_type not in allowed_types:
raise AppException(code=400, message="不支持的图片格式")
# 生成保存路径
import uuid as uuid_module
file_ext = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
file_name = f"{uuid_module.uuid4()}{file_ext}"
upload_dir = os.path.join("media", "images")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file_name)
# 保存文件
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# 返回文件URL
file_url = f"/media/images/{file_name}"
return success_response(
data={
"url": file_url,
"filename": file_name,
"file_size": file_size,
"content_type": content_type,
}
)
# --------------------------------------------------------------------------
# POST /api/messages/file — 上传文件
# --------------------------------------------------------------------------
@router.post("/messages/file")
async def upload_message_file(
file: UploadFile = File(...),
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""上传普通文件。
文件大小限制:10MB
Args:
file: 文件
db: 数据库会话
Returns:
Dict: 统一响应格式,包含文件URL和元数据
"""
# 校大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise AppException(code=400, message=f"文件大小超过10MB限制")
# 生成保存路径
import uuid as uuid_module
original_name = file.filename or "file"
file_ext = os.path.splitext(original_name)[1]
file_name = f"{uuid_module.uuid4()}{file_ext}"
upload_dir = os.path.join("media", "files")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file_name)
# 保存文件
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# 返回文件URL
file_url = f"/media/files/{file_name}"
return success_response(
data={
"url": file_url,
"filename": original_name,
"file_size": file_size,
"content_type": file.content_type,
}
)
+249
View File
@@ -0,0 +1,249 @@
# =============================================================================
# 企微IT智能服务台 — Portal 统一入口 API
# =============================================================================
# 说明:统一入口(Portal)相关接口
# 包含:
# 1. 获取当前用户角色信息
# 2. 切换当前角色
# 3. 获取角色对应的入口 URL
# 所有接口需要有效的 Bearer Token
# =============================================================================
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, UserInfo
from app.config import settings
from app.database import get_db
from app.models.role import Role
from app.models.user_role import UserRole
from app.schemas.role import (
PortalUserInfo,
RoleResponse,
SwitchRoleRequest,
SwitchRoleResponse,
)
from app.services.token_service import TokenService
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# HTTP Bearer 认证方案
security = HTTPBearer()
# 创建路由器
router = APIRouter(prefix="/portal")
# --------------------------------------------------------------------------
# 获取当前用户角色信息
# --------------------------------------------------------------------------
@router.get("/roles")
async def get_user_roles(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的角色信息。
返回用户的基本信息和角色列表,用于路由选择页展示。
Args:
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含用户信息和角色列表
"""
# 查询用户拥有的角色
stmt = (
select(Role, UserRole)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(
# 过滤已过期的角色
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > func.now())
)
)
result = await db.execute(stmt)
role_rows = result.all()
# 构建角色列表
roles = []
for role, user_role in role_rows:
roles.append(
RoleResponse(
id=role.id,
name=role.name,
display_name=role.display_name,
description=role.description,
permissions=role.permissions or [],
is_default=role.is_default,
created_at=role.created_at,
updated_at=role.updated_at,
)
)
# 如果用户没有任何角色,添加默认的 user 角色
if not roles:
# 查询 user 角色
user_role_stmt = select(Role).where(Role.name == "user")
user_role_result = await db.execute(user_role_stmt)
user_role = user_role_result.scalars().first()
if user_role:
roles.append(
RoleResponse(
id=user_role.id,
name=user_role.name,
display_name=user_role.display_name,
description=user_role.description,
permissions=user_role.permissions or [],
is_default=user_role.is_default,
created_at=user_role.created_at,
updated_at=user_role.updated_at,
)
)
# 构建响应
user_info = PortalUserInfo(
employee_id=current_user.employee_id,
name=current_user.name,
department=current_user.department,
avatar=current_user.avatar,
roles=roles,
current_role=current_user.current_role,
)
return success_response(data=user_info.model_dump())
# --------------------------------------------------------------------------
# 切换当前角色
# --------------------------------------------------------------------------
@router.post("/switch-role")
async def switch_role(
body: SwitchRoleRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""切换当前角色。
更新 Redis Token 中的 current_role 字段,返回目标角色的入口 URL。
Args:
body: 切换角色请求
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含切换后的角色和重定向 URL
"""
# 验证用户是否有目标角色
stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(Role.name == body.new_role)
)
result = await db.execute(stmt)
target_role = result.scalars().first()
if not target_role:
raise AppException(4003, f"没有 {body.new_role} 角色权限")
# 更新 Redis Token 中的 current_role
from app.dependencies import get_redis
redis_client = await get_redis()
token_service = TokenService(redis_client)
# 从请求头获取 token
token = credentials.credentials
switch_success = await token_service.switch_role(token, body.new_role)
if not switch_success:
raise AppException(4003, "角色切换失败")
# 获取目标角色的入口 URL
redirect_url = _get_role_url(body.new_role)
logger.info(f"用户 {current_user.employee_id} 切换角色到 {body.new_role}")
return success_response(
data=SwitchRoleResponse(
current_role=body.new_role,
redirect_url=redirect_url,
).model_dump()
)
# --------------------------------------------------------------------------
# 获取角色对应的入口 URL
# --------------------------------------------------------------------------
@router.get("/entry/{role_name}")
async def get_role_entry(
role_name: str,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取角色对应的入口 URL。
Args:
role_name: 角色标识
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含角色信息和入口 URL
"""
# 验证用户是否有目标角色
stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(Role.name == role_name)
)
result = await db.execute(stmt)
target_role = result.scalars().first()
if not target_role:
raise AppException(4003, f"没有 {role_name} 角色权限")
# 获取入口 URL
redirect_url = _get_role_url(role_name)
return success_response(
data={
"role": role_name,
"url": redirect_url,
"display_name": target_role.display_name,
}
)
# --------------------------------------------------------------------------
# 辅助函数:获取角色对应的 URL
# --------------------------------------------------------------------------
def _get_role_url(role_name: str) -> str:
"""获取角色对应的前端 URL。
Args:
role_name: 角色标识
Returns:
str: 前端 URL
"""
role_urls = {
"user": "/itdesk/",
"agent": "/itagent/",
"admin": "/itadmin/",
}
return role_urls.get(role_name, "/itdesk/")
+256
View File
@@ -0,0 +1,256 @@
# =============================================================================
# 企微IT智能服务台 — 快速回复模板 API
# =============================================================================
# 说明:坐席端的快速回复模板管理接口,包括:
# 1. GET /api/quick-replies — 获取模板列表(按分类)
# 2. POST /api/quick-replies — 创建模板
# 3. PUT /api/quick-replies/{id} — 更新模板
# 4. DELETE /api/quick-replies/{id} — 删除模板
# =============================================================================
import logging
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy import or_, and_
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.models.quick_reply_template import QuickReplyTemplate
from app.schemas.quick_reply import (
QuickReplyCreate,
QuickReplyResponse,
QuickReplyUpdate,
)
from app.utils.response import AppException, ERR_NOT_FOUND, ERR_UNAUTHORIZED, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 可选坐席认证(有 token 则认证,无 token 则跳过)
# --------------------------------------------------------------------------
async def get_optional_agent(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
) -> Optional[Agent]:
"""可选坐席认证依赖。
有 Authorization 头时尝试认证,无或认证失败时返回 None。
Args:
authorization: 请求头中的 Authorization 字段
db: 数据库会话
Returns:
Optional[Agent]: 认证成功返回坐席对象,否则返回 None
"""
if not authorization:
return None
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
if not token:
return None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
try:
agent_user_id = await redis_client.get(f"agent:token:{token}")
if not agent_user_id:
return None
uid = agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
stmt = select(Agent).where(Agent.user_id == uid)
result = await db.execute(stmt)
agent = result.scalars().first()
return agent
finally:
try:
await redis_client.close()
except Exception:
pass
except Exception as e:
logger.warning(f"可选坐席认证失败: {e}")
return None
# --------------------------------------------------------------------------
# GET /api/quick-replies — 获取模板列表
# --------------------------------------------------------------------------
@router.get("/quick-replies")
async def list_quick_replies(
category: Optional[str] = Query(None, description="按分类过滤: 账号/网络/软件/硬件/通用"),
db: AsyncSession = Depends(get_db),
agent: Optional[Agent] = Depends(get_optional_agent),
):
"""获取快速回复模板列表。
支持按分类过滤,按 sort_order 排序。
坐席端可见性规则:
- 有认证:返回 approved + 自己的 pending_review
- 无认证:只返回 approved
Args:
category: 按分类过滤(可选)
db: 数据库会话
agent: 当前坐席(可选认证)
Returns:
Dict: 统一响应格式,包含模板列表
"""
stmt = select(QuickReplyTemplate).order_by(
QuickReplyTemplate.category, QuickReplyTemplate.sort_order
)
if category:
stmt = stmt.where(QuickReplyTemplate.category == category)
# 状态筛选:坐席端可见性规则
if agent:
# 有认证:approved + 自己的 pending_review
stmt = stmt.where(
or_(
QuickReplyTemplate.status == "approved",
and_(
QuickReplyTemplate.status == "pending_review",
QuickReplyTemplate.submitted_by == agent.id,
),
)
)
else:
# 无认证:只返回 approved
stmt = stmt.where(QuickReplyTemplate.status == "approved")
result = await db.execute(stmt)
templates = list(result.scalars().all())
items = [QuickReplyResponse.model_validate(t).model_dump() for t in templates]
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# POST /api/quick-replies — 创建模板
# --------------------------------------------------------------------------
@router.post("/quick-replies")
async def create_quick_reply(
body: QuickReplyCreate,
db: AsyncSession = Depends(get_db),
):
"""创建快速回复模板。
Args:
body: 创建请求体(包含 category、title、content、variables、sort_order
db: 数据库会话
Returns:
Dict: 统一响应格式,包含创建的模板
"""
template = QuickReplyTemplate(
category=body.category,
title=body.title,
content=body.content,
variables=body.variables,
sort_order=body.sort_order,
)
db.add(template)
await db.flush()
logger.info(f"创建快速回复模板: category={body.category}, title={body.title}")
template_data = QuickReplyResponse.model_validate(template).model_dump()
return success_response(data=template_data)
# --------------------------------------------------------------------------
# PUT /api/quick-replies/{id} — 更新模板
# --------------------------------------------------------------------------
@router.put("/quick-replies/{template_id}")
async def update_quick_reply(
template_id: UUID,
body: QuickReplyUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新快速回复模板。
只更新传入的字段(部分更新)。
Args:
template_id: 模板ID
body: 更新请求体(所有字段可选)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的模板
"""
# 查找模板
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
result = await db.execute(stmt)
template = result.scalars().first()
if not template:
raise ERR_NOT_FOUND
# 只更新传入的字段
if body.category is not None:
template.category = body.category
if body.title is not None:
template.title = body.title
if body.content is not None:
template.content = body.content
if body.variables is not None:
template.variables = body.variables
if body.sort_order is not None:
template.sort_order = body.sort_order
db.add(template)
await db.flush()
logger.info(f"更新快速回复模板: id={template_id}")
template_data = QuickReplyResponse.model_validate(template).model_dump()
return success_response(data=template_data)
# --------------------------------------------------------------------------
# DELETE /api/quick-replies/{id} — 删除模板
# --------------------------------------------------------------------------
@router.delete("/quick-replies/{template_id}")
async def delete_quick_reply(
template_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""删除快速回复模板。
第一步使用物理删除。
Args:
template_id: 模板ID
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查找模板
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
result = await db.execute(stmt)
template = result.scalars().first()
if not template:
raise ERR_NOT_FOUND
# 物理删除
await db.delete(template)
await db.flush()
logger.info(f"删除快速回复模板: id={template_id}")
return success_response(data=None, message="删除成功")
+157
View File
@@ -0,0 +1,157 @@
# =============================================================================
# 企微IT智能服务台 — API 路由汇总
# =============================================================================
# 说明:汇总所有 API 子路由,统一挂载到 FastAPI 应用
# T02 阶段注册所有后端核心服务路由
# =============================================================================
from fastapi import APIRouter
# 导入各子路由模块
from app.api.wecom_callback import router as wecom_router
from app.api.conversations import router as conversations_router
from app.api.messages import router as messages_router
from app.api.agents import router as agents_router
from app.api.quick_replies import router as quick_replies_router
from app.api.h5 import router as h5_router
from app.api.agent_notes import router as agent_notes_router
from app.api.system import router as system_router
from app.api.wingman import router as wingman_router
from app.api.todo_items import router as todo_items_router
from app.api.troubleshooting_templates import router as troubleshooting_templates_router
from app.api.employees import router as employees_router
from app.api.upload import router as upload_router
from app.api.admin import router as admin_router
from app.api.portal import router as portal_router
from app.api.admin_roles import router as admin_roles_router
# 创建 API 路由器
# 所有子路由都会挂载到这个路由器上
api_router = APIRouter()
# --------------------------------------------------------------------------
# 注册所有子路由
# --------------------------------------------------------------------------
# 每个子路由都有对应的 prefix 和 tags,方便 Swagger 文档分类展示
# --------------------------------------------------------------------------
# 企微回调 API
# GET /api/wecom/callback — 验证URL有效性
# POST /api/wecom/callback — 接收企微推送消息
api_router.include_router(wecom_router, tags=["企微回调"])
# 会话管理 API
# GET /api/conversations — 获取会话列表
# GET /api/conversations/{id} — 获取会话详情
# POST /api/conversations/{id}/assign — 坐席接单
# POST /api/conversations/{id}/resolve — 结单
# POST /api/conversations/{id}/pin — 置顶/取消置顶
# POST /api/conversations/{id}/todo — 代办/取消代办
# POST /api/conversations/{id}/transfer — 转接
api_router.include_router(conversations_router, tags=["会话管理"])
# 消息管理 API
# GET /api/conversations/{id}/messages — 获取消息列表
# POST /api/conversations/{id}/messages — 坐席发送消息
# GET /api/conversations/{id}/messages/poll — 轮询新消息
api_router.include_router(messages_router, tags=["消息管理"])
# 坐席管理 API
# POST /api/agents/login — 坐席登录
# GET /api/agents/me — 获取当前坐席信息
# PUT /api/agents/me/status — 更新坐席状态
# GET /api/agents — 获取坐席列表
api_router.include_router(agents_router, tags=["坐席管理"])
# 快速回复模板 API
# GET /api/quick-replies — 获取模板列表
# POST /api/quick-replies — 创建模板
# PUT /api/quick-replies/{id} — 更新模板
# DELETE /api/quick-replies/{id} — 删除模板
api_router.include_router(quick_replies_router, tags=["快速回复"])
# H5 用户端 API
# POST /api/h5/oauth/callback — OAuth2回调
# GET /api/h5/user — 获取用户信息
# GET /api/h5/conversations/current — 获取当前会话
# POST /api/h5/conversations/current/messages — 发送消息
# GET /api/h5/conversations/current/messages/poll — 轮询新消息
# POST /api/h5/conversations/current/shake — 摇人
# GET /api/h5/approval-links — 获取审批链接
# GET /api/h5/software-downloads — 获取软件下载
api_router.include_router(h5_router, tags=["H5用户端"])
# 坐席备注 API
# GET /api/agent-notes/{employee_id} — 获取员工备注
# POST /api/agent-notes — 添加备注
# PUT /api/agent-notes/{id} — 更新备注
# DELETE /api/agent-notes/{id} — 删除备注
api_router.include_router(agent_notes_router, tags=["坐席备注"])
# 系统管理 API
# GET /api/system/emergency-mode — 查询应急模式状态
# PUT /api/system/emergency-mode — 切换应急模式开关
api_router.include_router(system_router, tags=["系统管理"])
# AI Wingman 智能副驾驶 API
# POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
# POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
# POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
api_router.include_router(wingman_router, tags=["AI Wingman"])
# 待办事项 API
# GET /api/todo-items — 获取当前坐席待办列表
# GET /api/todo-items/{id} — 获取待办详情
# PUT /api/todo-items/{id}/status — 更新待办状态
api_router.include_router(todo_items_router, tags=["待办事项"])
# 排查模板 API
# GET /api/troubleshooting-templates — 获取排查模板列表
# GET /api/troubleshooting-templates/{id} — 获取排查模板详情
# POST /api/troubleshooting-templates — 新增模板(管理员)
# PUT /api/troubleshooting-templates/{id} — 修改模板(管理员)
# DELETE /api/troubleshooting-templates/{id} — 删除模板(管理员)
api_router.include_router(troubleshooting_templates_router, tags=["排查模板"])
# 员工管理 API
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
api_router.include_router(employees_router, tags=["员工管理"])
# 文件上传 API
# POST /api/upload — 上传文件(图片/文档)
# GET /api/media/{year}/{month}/{day}/{filename} — 访问上传的文件
api_router.include_router(upload_router, tags=["文件上传"])
# 管理后台 API
# GET /api/admin/dashboard/overview — 仪表盘统计
# GET /api/admin/configs — 获取配置分组
# PUT /api/admin/configs/{key} — 更新配置项
# GET /api/admin/configs/{key}/history — 配置变更历史
# GET /api/admin/agents — 坐席列表(管理视图)
# POST /api/admin/agents — 添加坐席
# PUT /api/admin/agents/{id} — 编辑坐席
# DELETE /api/admin/agents/{id} — 移除坐席
# GET /api/admin/integrations — 集成系统列表
# PUT /api/admin/integrations/{id} — 更新集成配置
# GET /api/admin/quick-replies/pending — 待审核快速回复
# PUT /api/admin/quick-replies/{id}/review — 审核快速回复
# GET /api/admin/assignment-mode — 获取分配模式
# PUT /api/admin/assignment-mode — 切换分配模式
# GET /api/admin/monitor/sessions — 会话监控
# GET /api/admin/search — 全局搜索
api_router.include_router(admin_router, tags=["管理后台"])
# Portal 统一入口 API
# GET /api/portal/roles — 获取当前用户角色信息
# POST /api/portal/switch-role — 切换当前角色
# GET /api/portal/entry/{role} — 获取角色对应的入口 URL
api_router.include_router(portal_router, tags=["统一入口"])
# 管理后台角色管理 API
# GET /api/admin/roles — 获取所有角色
# POST /api/admin/roles/assign — 分配角色
# POST /api/admin/roles/revoke — 撤销角色
# GET /api/admin/roles/mapping-rules — 获取映射规则
# POST /api/admin/roles/mapping-rules — 创建映射规则
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
api_router.include_router(admin_roles_router, tags=["角色管理"])
+130
View File
@@ -0,0 +1,130 @@
# =============================================================================
# 企微IT智能服务台 — 系统管理 API
# =============================================================================
# 说明:系统级配置管理接口,包括:
# 1. GET /api/system/emergency-mode — 查询应急模式状态
# 2. PUT /api/system/emergency-mode — 切换应急模式开关
# =============================================================================
import logging
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.system_config import SystemConfig
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# 应急模式配置键(与 main.py init_data 保持一致)
EMERGENCY_MODE_KEY = "emergency_mode"
# --------------------------------------------------------------------------
# GET /api/system/emergency-mode — 查询应急模式状态
# --------------------------------------------------------------------------
@router.get("/system/emergency-mode")
async def get_emergency_mode(
db: AsyncSession = Depends(get_db),
):
"""查询应急模式状态。
返回当前应急模式的开关状态。
应急模式开启时,智能服务台降级,引导员工使用企微原生「员工服务」通道。
Returns:
Dict: 统一响应格式
data.emergency_mode: bool — 是否启用应急模式
data.employee_service_guide: str — 开启时的引导文案(仅开启时返回)
"""
# 从数据库读取 emergency_mode 配置
stmt = select(SystemConfig).where(
SystemConfig.config_key == EMERGENCY_MODE_KEY
)
result = await db.execute(stmt)
config = result.scalars().first()
# 配置不存在时默认关闭(安全默认值)
is_enabled = False
if config and config.config_value:
is_enabled = config.config_value.lower() in ("true", "1", "yes")
response_data = {"emergency_mode": is_enabled}
# 应急模式开启时,附带引导文案
if is_enabled:
response_data["employee_service_guide"] = (
"IT智能服务台正在进行系统维护,"
"请通过企业微信「通讯录 → 员工服务」联系IT支持人员,"
"我们将尽快为您处理。"
)
return success_response(data=response_data)
# --------------------------------------------------------------------------
# PUT /api/system/emergency-mode — 切换应急模式开关
# --------------------------------------------------------------------------
@router.put("/system/emergency-mode")
async def toggle_emergency_mode(
body: dict,
db: AsyncSession = Depends(get_db),
):
"""切换应急模式开关(仅限坐席/管理员操作)。
开启应急模式后:
- H5 用户端页面显示引导文案,提示走企微原生「员工服务」
- 坐席工作台顶部显示醒目的应急模式横幅
关闭应急模式后恢复正常服务。
Args:
body: 请求体,包含 emergency_mode: bool
Returns:
Dict: 统一响应格式
data.emergency_mode: bool — 切换后的状态
"""
enabled = body.get("emergency_mode", None)
if enabled is None:
raise AppException(1001, "emergency_mode 参数不能为空")
enabled_bool = bool(enabled)
# 查找或创建 emergency_mode 配置项
stmt = select(SystemConfig).where(
SystemConfig.config_key == EMERGENCY_MODE_KEY
)
result = await db.execute(stmt)
config = result.scalars().first()
new_value = "true" if enabled_bool else "false"
if config:
# 更新已有配置
config.config_value = new_value
else:
# 配置不存在时新建(兜底,正常情况由 init_data 创建)
config = SystemConfig(
config_key=EMERGENCY_MODE_KEY,
config_value=new_value,
description="应急模式开关(true=启用员工服务通道,智能服务台降级)",
)
db.add(config)
await db.flush()
status_text = "开启" if enabled_bool else "关闭"
logger.info(f"应急模式已{status_text}")
return success_response(
data={
"emergency_mode": enabled_bool,
"message": f"应急模式已{status_text}",
}
)
+439
View File
@@ -0,0 +1,439 @@
# =============================================================================
# 企微IT智能服务台 — 待办事项 API
# =============================================================================
# 说明:提供待办事项的 CRUD 接口
# 接口列表:
# GET /api/todo-items — 获取当前坐席待办列表
# GET /api/todo-items/{id} — 获取待办详情
# PUT /api/todo-items/{id}/status — 更新待办状态
# Mock: 预置示例待办数据,不连接真实外部系统
# =============================================================================
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.utils.response import success_response, AppException
# 创建路由器
router = APIRouter(prefix="/todo-items", tags=["待办事项"])
# --------------------------------------------------------------------------
# 请求/响应 Schema
# --------------------------------------------------------------------------
class TodoStatusUpdateRequest(BaseModel):
"""更新待办状态请求 Schema。"""
status: str = Field(..., description="新状态: pending/processing/resolved")
class TodoItemResponse(BaseModel):
"""待办事项响应 Schema。"""
id: str
type: str
title: str
priority: str
description: dict
status: str
assigned_agent_id: Optional[str] = None
corp_id: str = ""
created_at: str
updated_at: str
class TodoItemListResponse(BaseModel):
"""待办事项列表响应 Schema。"""
items: List[TodoItemResponse]
total: int
# --------------------------------------------------------------------------
# Mock 数据 — 预置示例待办(共 20 条,覆盖全部类型 × 状态)
# --------------------------------------------------------------------------
MOCK_TODO_ITEMS: List[dict] = [
# ========== 工单(ticket==========
# 待处理
{
"id": "todo-001",
"type": "ticket",
"title": "VPN连接失败 — 财务部张伟",
"priority": "urgent",
"description": {
"employee_name": "张伟",
"department": "财务部",
"error": "VPN Error 691",
"steps": ["检查账号状态", "重置密码", "检查VPN配置"],
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T09:15:00Z",
"updated_at": "2026-06-05T09:15:00Z",
},
{
"id": "todo-007",
"type": "ticket",
"title": "OA系统登录异常 — 人事部刘芳",
"priority": "urgent",
"description": {
"employee_name": "刘芳",
"department": "人事部",
"error": "页面白屏,控制台报500错误",
"affected_count": 15,
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T11:30:00Z",
"updated_at": "2026-06-05T11:30:00Z",
},
{
"id": "todo-009",
"type": "ticket",
"title": "WiFi 无法连接 — 研发部开放区",
"priority": "urgent",
"description": {
"employee_name": "陈明",
"department": "研发部",
"error": "获取IP失败,提示无法连接到此网络",
"location": "3楼开放区",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T08:00:00Z",
"updated_at": "2026-06-06T08:00:00Z",
},
{
"id": "todo-017",
"type": "ticket",
"title": "鼠标失灵 — 行政部周婷",
"priority": "normal",
"description": {
"employee_name": "周婷",
"department": "行政部",
"error": "USB鼠标间歇性失灵,更换接口无效",
"os": "Windows 11",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T09:00:00Z",
"updated_at": "2026-06-06T09:00:00Z",
},
# 进行中
{
"id": "todo-004",
"type": "ticket",
"title": "邮箱容量告警 — 市场部王强",
"priority": "high",
"description": {
"employee_name": "王强",
"department": "市场部",
"current_usage": "4.8GB / 5GB",
"action": "协助清理或申请扩容",
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-04T14:30:00Z",
"updated_at": "2026-06-05T08:00:00Z",
},
{
"id": "todo-010",
"type": "ticket",
"title": "ERP系统响应慢 — 全公司反馈",
"priority": "high",
"description": {
"employee_name": "多个员工",
"department": "全公司",
"error": "ERP首页加载超过15秒",
"affected_count": 50,
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T10:00:00Z",
"updated_at": "2026-06-05T15:00:00Z",
},
# 已完成
{
"id": "todo-011",
"type": "ticket",
"title": "打印机驱动安装 — 市场部赵敏",
"priority": "normal",
"description": {
"employee_name": "赵敏",
"department": "市场部",
"device_model": "Canon LBP2900",
"solution": "从官网下载驱动并安装,测试打印正常",
},
"status": "resolved",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-02T16:00:00Z",
},
# ========== 审批(approval==========
# 待处理
{
"id": "todo-002",
"type": "approval",
"title": "软件安装审批 — 设计部PS申请",
"priority": "high",
"description": {
"employee_name": "李娜",
"department": "设计部",
"software": "Adobe Photoshop 2026",
"license_type": "企业许可",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T10:20:00Z",
"updated_at": "2026-06-05T10:20:00Z",
},
{
"id": "todo-005",
"type": "approval",
"title": "权限升级审批 — 研发部数据库访问",
"priority": "high",
"description": {
"employee_name": "陈明",
"department": "研发部",
"target_system": "生产数据库",
"access_level": "只读",
"approver": "研发总监",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T08:45:00Z",
"updated_at": "2026-06-05T08:45:00Z",
},
{
"id": "todo-008",
"type": "approval",
"title": "新员工设备采购审批 — Q3批次",
"priority": "normal",
"description": {
"batch": "Q3新员工",
"count": 5,
"items": ["笔记本x5", "显示器x5", "键鼠套装x5"],
"budget": "65,000元",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T07:00:00Z",
"updated_at": "2026-06-05T07:00:00Z",
},
{
"id": "todo-018",
"type": "approval",
"title": "弹性福利审批 — 全体员工Q3",
"priority": "normal",
"description": {
"applicant": "人事部",
"type": "弹性福利",
"budget_per_person": "3000元",
"total_count": 120,
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T07:00:00Z",
"updated_at": "2026-06-06T07:00:00Z",
},
# 进行中
{
"id": "todo-012",
"type": "approval",
"title": "预算审批 — IT部Q3采购",
"priority": "high",
"description": {
"department": "IT部",
"amount": "280,000元",
"items": ["服务器x2", "防火墙x2", "交换机x4"],
"approver": "CFO",
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-04T09:00:00Z",
"updated_at": "2026-06-05T14:00:00Z",
},
# 已完成
{
"id": "todo-013",
"type": "approval",
"title": "会议室预订审批 — 销售部Q3客户拜访",
"priority": "normal",
"description": {
"employee_name": "刘军",
"department": "销售部",
"room": "5楼大会议室",
"time": "2026-06-10 14:00-17:00",
"result": "已批准",
},
"status": "resolved",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-05-28T08:00:00Z",
"updated_at": "2026-05-29T10:00:00Z",
},
# ========== 设备(device==========
# 待处理
{
"id": "todo-003",
"type": "device",
"title": "工位打印机故障 — 3楼A区",
"priority": "normal",
"description": {
"location": "3楼A区打印间",
"device_model": "HP LaserJet Pro M404",
"issue": "卡纸,无法打印",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T11:05:00Z",
"updated_at": "2026-06-05T11:05:00Z",
},
{
"id": "todo-014",
"type": "device",
"title": "核心交换机故障 — 机房",
"priority": "urgent",
"description": {
"location": "机房A区",
"device_model": "Cisco Catalyst 9300",
"issue": "端口3-12全部down,影响2楼所有工位",
"affected_count": 45,
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T00:30:00Z",
"updated_at": "2026-06-06T00:30:00Z",
},
# 进行中
{
"id": "todo-006",
"type": "device",
"title": "会议室投影仪维修 — 5楼大会议室",
"priority": "normal",
"description": {
"location": "5楼大会议室",
"device_model": "Epson EB-X51",
"issue": "投影模糊,可能灯泡老化",
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-03T16:00:00Z",
"updated_at": "2026-06-04T10:00:00Z",
},
{
"id": "todo-015",
"type": "device",
"title": "服务器硬盘更换 — 虚拟化集群",
"priority": "high",
"description": {
"location": "机房B区",
"device_model": "Dell R740",
"issue": "硬盘预警,需更换并做好数据迁移",
"affected_vms": 12,
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T09:00:00Z",
"updated_at": "2026-06-05T16:00:00Z",
},
# 已完成
{
"id": "todo-016",
"type": "device",
"title": "员工笔记本磁盘扩容 — 人事部吴婷",
"priority": "normal",
"description": {
"employee_name": "吴婷",
"department": "人事部",
"device_model": "ThinkPad X1 Carbon",
"solution": "更换1TB SSD,克隆系统,测试正常",
},
"status": "resolved",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-05-20T13:00:00Z",
"updated_at": "2026-05-22T17:00:00Z",
},
]
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.get("")
async def list_todo_items(
status: Optional[str] = None,
priority: Optional[str] = None,
):
"""获取当前坐席待办列表。
支持按状态和优先级过滤。
"""
items = MOCK_TODO_ITEMS
# 按状态过滤
if status:
items = [item for item in items if item["status"] == status]
# 按优先级过滤
if priority:
items = [item for item in items if item["priority"] == priority]
# 按优先级排序:urgent → high → normal
priority_order = {"urgent": 0, "high": 1, "normal": 2}
items = sorted(items, key=lambda x: priority_order.get(x["priority"], 3))
return success_response(data={
"items": [TodoItemResponse(**item).model_dump() for item in items],
"total": len(items),
})
@router.get("/{item_id}")
async def get_todo_item(item_id: str):
"""获取待办事项详情。"""
for item in MOCK_TODO_ITEMS:
if item["id"] == item_id:
return success_response(data=TodoItemResponse(**item).model_dump())
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
@router.put("/{item_id}/status")
async def update_todo_item_status(item_id: str, request: TodoStatusUpdateRequest):
"""更新待办事项状态。"""
# 校验状态值
valid_statuses = {"pending", "processing", "resolved"}
if request.status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"无效的状态值: {request.status},合法值为: {valid_statuses}",
)
for item in MOCK_TODO_ITEMS:
if item["id"] == item_id:
item["status"] = request.status
item["updated_at"] = datetime.now().isoformat()
return success_response(data=TodoItemResponse(**item).model_dump())
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
@@ -0,0 +1,719 @@
# =============================================================================
# 企微IT智能服务台 — 排查模板 API
# =============================================================================
# 说明:提供排查模板的 CRUD 接口
# 接口列表:
# GET /api/troubleshooting-templates — 获取排查模板列表
# GET /api/troubleshooting-templates/{id} — 获取排查模板详情
# POST /api/troubleshooting-templates — 新增模板(管理员)
# PUT /api/troubleshooting-templates/{id} — 修改模板(管理员)
# DELETE /api/troubleshooting-templates/{id} — 删除模板(管理员)
# Mock: 预置 8 套常见问题模板(VPN/邮箱/系统/账号等)
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.utils.response import success_response, AppException
# 创建路由器
router = APIRouter(prefix="/troubleshooting-templates", tags=["排查模板"])
# --------------------------------------------------------------------------
# 请求/响应 Schema
# --------------------------------------------------------------------------
class PathStepSchema(BaseModel):
"""排障步骤路径节点 Schema。"""
label: str = Field(..., description="步骤标题")
status: str = Field(default="pending", description="步骤状态: done/current/pending")
class FlowchartNodeSchema(BaseModel):
"""决策树递归节点 Schema。"""
id: str = Field(..., description="节点唯一标识")
type: str = Field(..., description="节点类型: step/decision")
label: str = Field(..., description="节点标签")
status: Optional[str] = Field(None, description="节点状态: done/current/pending")
children: Optional[List["FlowchartNodeSchema"]] = Field(None, description="子节点列表")
yes_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'' 分支")
no_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'' 分支")
class TroubleshootingTemplateCreateRequest(BaseModel):
"""创建排查模板请求 Schema。"""
name: str = Field(..., min_length=1, max_length=256, description="模板名称")
category: str = Field(default="system", description="分类: vpn/email/system/account")
path_steps: List[Dict[str, Any]] = Field(default_factory=list, description="排障步骤路径")
flowchart: Dict[str, Any] = Field(default_factory=dict, description="流程图定义")
is_active: bool = Field(default=True, description="是否启用")
class TroubleshootingTemplateUpdateRequest(BaseModel):
"""更新排查模板请求 Schema。"""
name: Optional[str] = Field(None, max_length=256, description="模板名称")
category: Optional[str] = Field(None, description="分类")
path_steps: Optional[List[Dict[str, Any]]] = Field(None, description="排障步骤路径")
flowchart: Optional[Dict[str, Any]] = Field(None, description="流程图定义")
is_active: Optional[bool] = Field(None, description="是否启用")
class TroubleshootingTemplateResponse(BaseModel):
"""排查模板响应 Schema。"""
id: str
name: str
category: str
path_steps: List[Dict[str, Any]]
flowchart: Dict[str, Any]
is_active: bool
created_at: str
updated_at: str
class TroubleshootingTemplateListResponse(BaseModel):
"""排查模板列表响应 Schema。"""
items: List[TroubleshootingTemplateResponse]
total: int
# --------------------------------------------------------------------------
# Mock 数据 — 预置 8 套常见问题模板
# --------------------------------------------------------------------------
def _build_vpn_flowchart() -> Dict[str, Any]:
"""构建 VPN 故障排查流程图。"""
return {
"id": "fc-vpn-1",
"type": "step",
"label": "确认VPN客户端版本",
"status": "done",
"children": [
{
"id": "fc-vpn-2",
"type": "decision",
"label": "版本是否为最新?",
"status": "pending",
"yes_branch": {
"id": "fc-vpn-3",
"type": "step",
"label": "清除DNS缓存并重连",
"status": "current",
"children": [
{
"id": "fc-vpn-4",
"type": "decision",
"label": "重连是否成功?",
"status": "pending",
"yes_branch": {
"id": "fc-vpn-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-vpn-6",
"type": "step",
"label": "发起远程协助",
"status": "pending",
"children": [
{
"id": "fc-vpn-7",
"type": "decision",
"label": "远程能否解决?",
"status": "pending",
"yes_branch": {
"id": "fc-vpn-8",
"type": "step",
"label": "回访确认并结单",
"status": "pending",
},
"no_branch": {
"id": "fc-vpn-9",
"type": "step",
"label": "升级至二线团队",
"status": "pending",
},
},
],
},
},
],
},
"no_branch": {
"id": "fc-vpn-10",
"type": "step",
"label": "升级VPN客户端到最新版",
"status": "pending",
"children": [
{
"id": "fc-vpn-11",
"type": "step",
"label": "重试连接",
"status": "pending",
},
],
},
},
],
}
def _build_email_flowchart() -> Dict[str, Any]:
"""构建邮箱故障排查流程图。"""
return {
"id": "fc-email-1",
"type": "step",
"label": "确认邮箱账号状态",
"status": "done",
"children": [
{
"id": "fc-email-2",
"type": "decision",
"label": "账号是否被锁定?",
"status": "pending",
"yes_branch": {
"id": "fc-email-3",
"type": "step",
"label": "解锁账号并重置密码",
"status": "current",
},
"no_branch": {
"id": "fc-email-4",
"type": "step",
"label": "检查Outlook配置",
"status": "pending",
"children": [
{
"id": "fc-email-5",
"type": "decision",
"label": "配置是否正确?",
"status": "pending",
"yes_branch": {
"id": "fc-email-6",
"type": "step",
"label": "清理Outlook缓存",
"status": "pending",
},
"no_branch": {
"id": "fc-email-7",
"type": "step",
"label": "重新配置Outlook",
"status": "pending",
},
},
],
},
},
],
}
def _build_system_flowchart() -> Dict[str, Any]:
"""构建系统登录异常排查流程图。"""
return {
"id": "fc-sys-1",
"type": "step",
"label": "确认系统服务是否正常",
"status": "current",
"children": [
{
"id": "fc-sys-2",
"type": "decision",
"label": "系统服务是否正常?",
"status": "pending",
"yes_branch": {
"id": "fc-sys-3",
"type": "step",
"label": "清除浏览器缓存",
"status": "pending",
"children": [
{
"id": "fc-sys-4",
"type": "decision",
"label": "清除后是否恢复?",
"status": "pending",
"yes_branch": {
"id": "fc-sys-5",
"type": "step",
"label": "回访确认并结单",
"status": "pending",
},
"no_branch": {
"id": "fc-sys-6",
"type": "step",
"label": "更换浏览器重试",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-sys-7",
"type": "step",
"label": "联系运维检查服务端",
"status": "pending",
},
},
],
}
def _build_account_flowchart() -> Dict[str, Any]:
"""构建账号权限问题排查流程图。"""
return {
"id": "fc-acc-1",
"type": "step",
"label": "确认权限需求与合规性",
"status": "current",
"children": [
{
"id": "fc-acc-2",
"type": "decision",
"label": "权限是否符合策略?",
"status": "pending",
"yes_branch": {
"id": "fc-acc-3",
"type": "step",
"label": "提交权限审批流程",
"status": "pending",
"children": [
{
"id": "fc-acc-4",
"type": "step",
"label": "审批通过后配置权限",
"status": "pending",
},
],
},
"no_branch": {
"id": "fc-acc-5",
"type": "step",
"label": "建议替代方案或申请特批",
"status": "pending",
},
},
],
}
def _build_network_flowchart() -> Dict[str, Any]:
"""构建网络连接问题排查流程图。"""
return {
"id": "fc-net-1",
"type": "step",
"label": "确认网络连接状态",
"status": "current",
"children": [
{
"id": "fc-net-2",
"type": "decision",
"label": "能否ping通网关?",
"status": "pending",
"yes_branch": {
"id": "fc-net-3",
"type": "step",
"label": "检查DNS解析",
"status": "pending",
"children": [
{
"id": "fc-net-4",
"type": "decision",
"label": "DNS是否正常?",
"status": "pending",
"yes_branch": {
"id": "fc-net-5",
"type": "step",
"label": "检查防火墙规则",
"status": "pending",
},
"no_branch": {
"id": "fc-net-6",
"type": "step",
"label": "手动配置DNS服务器",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-net-7",
"type": "step",
"label": "检查网线和交换机端口",
"status": "pending",
},
},
],
}
def _build_printer_flowchart() -> Dict[str, Any]:
"""构建打印机故障排查流程图。"""
return {
"id": "fc-prt-1",
"type": "step",
"label": "确认打印机连接状态",
"status": "current",
"children": [
{
"id": "fc-prt-2",
"type": "decision",
"label": "打印机是否在线?",
"status": "pending",
"yes_branch": {
"id": "fc-prt-3",
"type": "step",
"label": "清除打印队列并重启打印服务",
"status": "pending",
"children": [
{
"id": "fc-prt-4",
"type": "decision",
"label": "打印是否恢复?",
"status": "pending",
"yes_branch": {
"id": "fc-prt-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-prt-6",
"type": "step",
"label": "重新安装打印机驱动",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-prt-7",
"type": "step",
"label": "检查网络连接和打印机电源",
"status": "pending",
},
},
],
}
def _build_office_flowchart() -> Dict[str, Any]:
"""构建 Office 软件问题排查流程图。"""
return {
"id": "fc-off-1",
"type": "step",
"label": "确认Office版本和激活状态",
"status": "current",
"children": [
{
"id": "fc-off-2",
"type": "decision",
"label": "Office是否正常激活?",
"status": "pending",
"yes_branch": {
"id": "fc-off-3",
"type": "step",
"label": "修复Office安装",
"status": "pending",
"children": [
{
"id": "fc-off-4",
"type": "decision",
"label": "修复后是否正常?",
"status": "pending",
"yes_branch": {
"id": "fc-off-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-off-6",
"type": "step",
"label": "卸载重装Office",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-off-7",
"type": "step",
"label": "重新激活Office许可证",
"status": "pending",
},
},
],
}
def _build_password_flowchart() -> Dict[str, Any]:
"""构建密码重置问题排查流程图。"""
return {
"id": "fc-pwd-1",
"type": "step",
"label": "确认账号状态和锁定原因",
"status": "current",
"children": [
{
"id": "fc-pwd-2",
"type": "decision",
"label": "账号是否被锁定?",
"status": "pending",
"yes_branch": {
"id": "fc-pwd-3",
"type": "step",
"label": "解锁账号并引导自助重置",
"status": "pending",
"children": [
{
"id": "fc-pwd-4",
"type": "decision",
"label": "自助重置是否成功?",
"status": "pending",
"yes_branch": {
"id": "fc-pwd-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-pwd-6",
"type": "step",
"label": "管理员手动重置密码",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-pwd-7",
"type": "step",
"label": "检查SSO单点登录配置",
"status": "pending",
},
},
],
}
# 所有 Mock 模板数据
MOCK_TEMPLATES: List[dict] = [
{
"id": "tpl-vpn-001",
"name": "VPN连接故障",
"category": "vpn",
"path_steps": [
{"label": "确认VPN版本", "status": "done"},
{"label": "清除缓存重连", "status": "current"},
{"label": "远程排查", "status": "pending"},
{"label": "升级客户端", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_vpn_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-15T10:30:00Z",
},
{
"id": "tpl-email-001",
"name": "邮箱登录故障",
"category": "email",
"path_steps": [
{"label": "确认邮箱状态", "status": "done"},
{"label": "重置密码", "status": "current"},
{"label": "检查配置", "status": "pending"},
{"label": "清理缓存", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_email_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-20T14:00:00Z",
},
{
"id": "tpl-system-001",
"name": "系统登录异常",
"category": "system",
"path_steps": [
{"label": "确认系统状态", "status": "current"},
{"label": "清除浏览器缓存", "status": "pending"},
{"label": "更换浏览器", "status": "pending"},
{"label": "检查网络权限", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_system_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-25T09:15:00Z",
},
{
"id": "tpl-account-001",
"name": "账号权限问题",
"category": "account",
"path_steps": [
{"label": "确认权限需求", "status": "current"},
{"label": "提交审批", "status": "pending"},
{"label": "配置权限", "status": "pending"},
{"label": "验证权限", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_account_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-28T16:45:00Z",
},
{
"id": "tpl-network-001",
"name": "网络连接问题",
"category": "system",
"path_steps": [
{"label": "确认网络状态", "status": "current"},
{"label": "检查DNS配置", "status": "pending"},
{"label": "检查防火墙", "status": "pending"},
{"label": "更换网口/网线", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_network_flowchart(),
"is_active": True,
"created_at": "2025-06-05T10:00:00Z",
"updated_at": "2025-06-22T11:30:00Z",
},
{
"id": "tpl-printer-001",
"name": "打印机故障",
"category": "system",
"path_steps": [
{"label": "确认打印机状态", "status": "current"},
{"label": "清除打印队列", "status": "pending"},
{"label": "重新安装驱动", "status": "pending"},
{"label": "检查网络连接", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_printer_flowchart(),
"is_active": True,
"created_at": "2025-06-10T09:00:00Z",
"updated_at": "2025-07-01T08:00:00Z",
},
{
"id": "tpl-office-001",
"name": "Office软件问题",
"category": "system",
"path_steps": [
{"label": "确认Office版本", "status": "current"},
{"label": "修复安装", "status": "pending"},
{"label": "重新激活", "status": "pending"},
{"label": "卸载重装", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_office_flowchart(),
"is_active": True,
"created_at": "2025-06-12T14:00:00Z",
"updated_at": "2025-06-30T10:00:00Z",
},
{
"id": "tpl-password-001",
"name": "密码重置问题",
"category": "account",
"path_steps": [
{"label": "确认账号状态", "status": "current"},
{"label": "解锁账号", "status": "pending"},
{"label": "引导自助重置", "status": "pending"},
{"label": "管理员重置", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_password_flowchart(),
"is_active": True,
"created_at": "2025-06-15T08:00:00Z",
"updated_at": "2025-07-01T09:00:00Z",
},
]
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.get("")
async def list_troubleshooting_templates(
category: Optional[str] = None,
):
"""获取排查模板列表。
支持按分类过滤。
"""
items = MOCK_TEMPLATES
# 按分类过滤
if category:
items = [item for item in items if item["category"] == category]
# 只返回启用的模板
items = [item for item in items if item.get("is_active", True)]
return success_response(data={
"items": [TroubleshootingTemplateResponse(**item).model_dump() for item in items],
"total": len(items),
})
@router.get("/{template_id}")
async def get_troubleshooting_template(template_id: str):
"""获取排查模板详情。"""
for item in MOCK_TEMPLATES:
if item["id"] == template_id:
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
@router.post("")
async def create_troubleshooting_template(request: TroubleshootingTemplateCreateRequest):
"""新增排查模板(管理员)。"""
new_template = {
"id": f"tpl-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"name": request.name,
"category": request.category,
"path_steps": request.path_steps,
"flowchart": request.flowchart,
"is_active": request.is_active,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
MOCK_TEMPLATES.append(new_template)
return success_response(data=TroubleshootingTemplateResponse(**new_template).model_dump())
@router.put("/{template_id}")
async def update_troubleshooting_template(
template_id: str,
request: TroubleshootingTemplateUpdateRequest,
):
"""修改排查模板(管理员)。"""
for item in MOCK_TEMPLATES:
if item["id"] == template_id:
if request.name is not None:
item["name"] = request.name
if request.category is not None:
item["category"] = request.category
if request.path_steps is not None:
item["path_steps"] = request.path_steps
if request.flowchart is not None:
item["flowchart"] = request.flowchart
if request.is_active is not None:
item["is_active"] = request.is_active
item["updated_at"] = datetime.now().isoformat()
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
@router.delete("/{template_id}")
async def delete_troubleshooting_template(template_id: str):
"""删除排查模板(管理员)。"""
for i, item in enumerate(MOCK_TEMPLATES):
if item["id"] == template_id:
MOCK_TEMPLATES.pop(i)
return success_response(data=None, message=f"排查模板 {template_id} 已删除")
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
+206
View File
@@ -0,0 +1,206 @@
# =============================================================================
# 企微IT智能服务台 — 文件上传 API
# =============================================================================
# 说明:处理图片/文件上传,保存到服务器本地存储
# 1. POST /api/upload — 上传文件(图片/文件),返回文件URL
# 2. GET /api/media/{path} — 静态文件服务(开发环境)
# 文件存储路径:./uploads/YYYY/MM/DD/{uuid}.{ext}
# =============================================================================
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from app.utils.response import success_response
from app.api.h5 import _get_current_employee
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 文件存储配置
# --------------------------------------------------------------------------
# 上传文件的根目录(Docker 环境中映射为 Volume 持久化存储)
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads"))
# 允许上传的文件扩展名(白名单,防止上传可执行文件等危险文件)
ALLOWED_EXTENSIONS = {
# 图片
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg",
# 文档
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"txt", "csv", "md", "rtf",
# 压缩包
"zip", "rar", "7z", "tar", "gz",
# 其他
"log", "json", "xml", "yaml", "yml",
}
# 单文件最大大小(默认 20MB
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(20 * 1024 * 1024))) # 20MB
# 图片最大大小(默认 10MB
MAX_IMAGE_SIZE = int(os.getenv("MAX_IMAGE_SIZE", str(10 * 1024 * 1024))) # 10MB
# 图片类型扩展名集合
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"}
def _get_file_extension(filename: str) -> str:
"""从文件名中提取小写扩展名。
Args:
filename: 原始文件名
Returns:
str: 小写扩展名(不含点号),如 "png"
"""
# os.path.splitext 返回 (root, ext)ext 含点号如 ".png"
ext = os.path.splitext(filename)[1].lower().lstrip(".")
return ext or "bin" # 无扩展名时默认 bin
def _generate_storage_path(extension: str) -> tuple[Path, str]:
"""生成文件存储路径(按日期分目录)。
目录结构:uploads/YYYY/MM/DD/{uuid}.{ext}
同时返回完整本地路径和用于API访问的相对URL路径。
Args:
extension: 文件扩展名(如 "png"
Returns:
tuple: (本地文件完整路径, API访问的URL路径)
"""
now = datetime.now()
# 按日期建子目录,方便按时间归档和清理
date_dir = UPLOAD_DIR / f"{now.year}" / f"{now.month:02d}" / f"{now.day:02d}"
# 确保目录存在(exist_ok=True 避免并发创建时报错)
date_dir.mkdir(parents=True, exist_ok=True)
# 使用 UUID 避免文件名冲突和安全风险(不使用原始文件名存储)
file_id = uuid.uuid4().hex[:12] # 12位足够短且唯一
filename = f"{file_id}.{extension}"
local_path = date_dir / filename
# URL 路径:/api/media/YYYY/MM/DD/{uuid}.{ext}
url_path = f"/api/media/{now.year}/{now.month:02d}/{now.day:02d}/{filename}"
return local_path, url_path
# --------------------------------------------------------------------------
# POST /api/upload — 上传文件
# --------------------------------------------------------------------------
@router.post("/upload")
async def upload_file(
file: UploadFile = File(..., description="上传的文件(图片或文档)"),
employee_id: str = Depends(_get_current_employee),
):
"""上传文件到服务器。
处理流程:
1. 校验文件扩展名(白名单)
2. 校验文件大小(图片10MB,其他20MB)
3. 按日期目录存储文件
4. 返回文件访问URL
Args:
file: FastAPI UploadFile 对象
Returns:
Dict: 统一响应格式,包含文件URL、文件名、文件大小、文件类型
"""
# 1. 提取并校验文件扩展名
ext = _get_file_extension(file.filename or "unknown")
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: .{ext},允许的类型: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
)
# 2. 读取文件内容并校验大小
content = await file.read()
file_size = len(content)
# 图片和普通文件分别校验大小
is_image = ext in IMAGE_EXTENSIONS
max_size = MAX_IMAGE_SIZE if is_image else MAX_FILE_SIZE
size_label = "10MB" if is_image else "20MB"
if file_size > max_size:
raise HTTPException(
status_code=400,
detail=f"文件大小 {file_size / 1024 / 1024:.1f}MB 超过限制({size_label}",
)
# 3. 生成存储路径并保存文件
local_path, url_path = _generate_storage_path(ext)
try:
# 以二进制模式写入文件
with open(local_path, "wb") as f:
f.write(content)
except OSError as e:
logger.error(f"文件保存失败: {e}")
raise HTTPException(status_code=500, detail="文件保存失败,请重试")
# 4. 返回文件信息
logger.info(f"文件上传成功: {url_path} ({file_size} bytes, {file.filename})")
return success_response(data={
"url": url_path, # 文件访问URL(前端用于展示/下载)
"filename": file.filename, # 原始文件名(显示用)
"file_size": file_size, # 文件大小(字节)
"msg_type": "image" if is_image else "file", # 消息类型(前端根据此字段区分展示)
"extension": ext, # 文件扩展名
})
# --------------------------------------------------------------------------
# GET /api/media/{year}/{month}/{day}/{filename} — 静态文件服务
# --------------------------------------------------------------------------
# 注意:生产环境由 Nginx 直接提供静态文件服务(性能更好)
# 此接口仅用于开发环境,或 Nginx 未配置静态文件时的降级方案
@router.get("/media/{year}/{month}/{day}/{filename}")
async def serve_media_file(
year: str,
month: str,
day: str,
filename: str,
):
"""提供上传文件的静态访问。
开发环境使用 FastAPI 直接返回文件;
生产环境建议 Nginx 配置 location /api/media/ 直接代理到 uploads 目录。
Args:
year: 年份(路径参数)
month: 月份(路径参数)
day: 日期(路径参数)
filename: 文件名(路径参数)
Returns:
FileResponse: 文件响应
"""
file_path = UPLOAD_DIR / year / month / day / filename
# 安全检查:防止路径遍历攻击(如 ../../etc/passwd
# resolve() 解析符号链接和 .. ,然后检查是否在 UPLOAD_DIR 内
try:
resolved = file_path.resolve()
upload_root = 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="文件不存在")
# FileResponse 自动根据扩展名设置 Content-Type
return FileResponse(file_path)
+276
View File
@@ -0,0 +1,276 @@
# =============================================================================
# 企微IT智能服务台 — 企微回调 API
# =============================================================================
# 说明:处理企微服务器的回调请求,包括:
# 1. GET /api/wecom/callback — 验证URL有效性(企微配置回调URL时调用)
# 2. POST /api/wecom/callback — 接收企微推送的消息
#
# 重构记录(2026-06):
# - 移除手动创建 Redis/WecomService/AIService 实例的模式
# - 改用 dependencies 模块提供的共享服务实例
# - 不再手动 close() 服务实例(由应用生命周期管理)
# =============================================================================
import logging
from fastapi import APIRouter, Query, Request
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import _get_session_factory
from app.dependencies import (
get_shared_redis,
get_shared_wecom_service,
get_shared_ai_handler,
)
from app.services.ai_handler import AIHandler
from app.services.cache_service import CacheService
from app.services.message_router import MessageRouter
from app.services.scoring_service import ScoringService
from app.services.wecom_service import WecomService
from app.utils.wecom_crypto import WecomCrypto
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# 加解密工具实例(懒加载单例,避免导入时因无效配置导致 base64 解码失败)
_wecom_crypto: WecomCrypto | None = None
def _get_wecom_crypto() -> WecomCrypto:
"""获取加解密工具单例(延迟初始化)。
在测试环境中,settings 中的 EncodingAESKey 可能是无效的占位值,
延迟初始化可以避免模块导入时就触发 base64 解码错误。
"""
global _wecom_crypto
if _wecom_crypto is None:
from app.config import settings
_wecom_crypto = WecomCrypto(
token=settings.wecom_token,
encoding_aes_key=settings.wecom_encoding_aes_key,
corp_id=settings.wecom_corp_id,
)
return _wecom_crypto
@router.get("/wecom/callback")
async def verify_url(
msg_signature: str = Query(..., description="企微签名"),
timestamp: str = Query(..., description="时间戳"),
nonce: str = Query(..., description="随机数"),
echostr: str = Query(..., description="加密的验证字符串"),
):
"""验证企微回调URL有效性。
企微管理后台配置回调URL时,会发送 GET 请求验证。
验证流程:
1. 验证签名 SHA1(sort(token, timestamp, nonce, echostr))
2. 解密 echostr
3. 返回解密后的明文
Args:
msg_signature: 企微签名
timestamp: 时间戳
nonce: 随机数
echostr: 加密的验证字符串
Returns:
str: 解密后的 echostr 明文
"""
try:
# 验证签名并解密 echostr
plaintext = _get_wecom_crypto().decrypt_echostr(
msg_signature=msg_signature,
timestamp=timestamp,
nonce=nonce,
echostr=echostr,
)
logger.info("企微回调URL验证成功")
return Response(content=plaintext, media_type="text/plain")
except ValueError as e:
logger.error(f"企微回调URL验证失败: {e}")
return Response(content=f"验证失败: {e}", media_type="text/plain", status_code=400)
@router.post("/wecom/callback")
async def receive_message(
request: Request,
msg_signature: str = Query(..., description="企微签名"),
timestamp: str = Query(..., description="时间戳"),
nonce: str = Query(..., description="随机数"),
):
"""接收企微推送的消息。
企微将员工发送的消息通过此接口推送过来。
处理流程:
1. 读取 XML 请求体
2. 解密消息(验证签名 + AES 解密)
3. 解析消息内容
4. 路由到 MessageRouter 处理
5. 返回 "success" 字符串(企微要求)
重构说明:使用 dependencies 模块提供的共享服务实例,
不再手动创建/关闭 Redis、WecomService、AIService。
企微推送的消息格式(加密后):
<xml>
<ToUserName><![CDATA[corp_id]]></ToUserName>
<AgentID>1000002</AgentID>
<Encrypt><![CDATA[加密内容]]></Encrypt>
</xml>
Args:
request: FastAPI 请求对象(读取 XML 请求体)
msg_signature: 企微签名
timestamp: 时间戳
nonce: 随机数
Returns:
str: "success" 字符串(企微要求的固定响应)
"""
try:
# 1. 读取 XML 请求体
xml_body = (await request.body()).decode("utf-8")
logger.debug(f"收到企微回调: xml_length={len(xml_body)}")
# 2. 解密消息
message_dict = _get_wecom_crypto().decrypt_message(
xml_body=xml_body,
msg_signature=msg_signature,
timestamp=timestamp,
nonce=nonce,
)
# 3. 提取消息关键字段
from_user_id = message_dict.get("FromUserName", "")
content = message_dict.get("Content", "")
msg_type = message_dict.get("MsgType", "text")
agent_id = message_dict.get("AgentID", "")
event = message_dict.get("Event", "")
msg_id = message_dict.get("MsgId", "")
# 提取非文本消息的媒体字段(图片/语音/视频/文件/位置)
media_id: str = message_dict.get("MediaId", "")
pic_url: str = message_dict.get("PicUrl", "")
msg_format: str = message_dict.get("Format", "")
file_name: str = message_dict.get("FileName", "")
file_size: str = message_dict.get("FileSize", "")
# 位置消息字段
location_x: str = message_dict.get("Location_X", "")
location_y: str = message_dict.get("Location_Y", "")
location_label: str = message_dict.get("Label", "")
# 4. 处理事件消息(如员工进入应用)
if event:
await _handle_event(event, from_user_id, message_dict)
return Response(content="success", media_type="text/plain")
# 5. 处理各类消息(文本 + 非文本)
# 文本消息必须有 Content 字段;非文本消息(image/voice/video/file/location
# 没有 Content 字段,content 可能为空字符串,这是正常的
if msg_type == "text" and (not from_user_id or not content):
logger.warning("文本消息缺少发送者或内容,忽略")
return Response(content="success", media_type="text/plain")
elif msg_type != "text" and not from_user_id:
logger.warning("非文本消息缺少发送者,忽略")
return Response(content="success", media_type="text/plain")
# 6. 路由消息到 MessageRouter(使用共享服务实例)
session_factory = _get_session_factory()
async with session_factory() as db:
try:
# 获取共享服务实例(不再手动创建/关闭)
wecom_service = get_shared_wecom_service()
ai_handler = get_shared_ai_handler()
redis_client = get_shared_redis()
# ScoringService 需要当前 db 会话,仍需按请求创建
scoring_service = ScoringService(db)
# CacheService 使用共享 Redis 客户端
cache_service = CacheService(redis_client)
# 创建消息路由器
message_router = MessageRouter(
db=db,
wecom_service=wecom_service,
scoring_service=scoring_service,
ai_handler=ai_handler,
cache_service=cache_service,
)
# 构建 extra_data(存储各消息类型的额外元数据)
extra_data: dict = {}
if msg_type == "image":
extra_data["pic_url"] = pic_url
elif msg_type == "voice":
extra_data["format"] = msg_format
elif msg_type == "video":
extra_data["thumb_media_id"] = message_dict.get("ThumbMediaId", "")
elif msg_type == "location":
extra_data["location_x"] = location_x
extra_data["location_y"] = location_y
extra_data["label"] = location_label
extra_data["scale"] = message_dict.get("Scale", "")
# 路由消息
await message_router.route_message(
from_user_id=from_user_id,
content=content,
msg_type=msg_type,
msg_id=msg_id if msg_id else None,
media_id=media_id if media_id else None,
extra_data=extra_data if extra_data else None,
file_name=file_name if file_name else None,
file_size=int(file_size) if file_size else None,
)
# 提交事务
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"消息路由处理失败: {e}", exc_info=True)
# 即使处理失败,也返回 "success" 避免企微重试
# 但记录错误日志以便排查
return Response(content="success", media_type="text/plain")
except ValueError as e:
# 解密失败,记录日志但仍返回 success 避免企微重试
logger.error(f"消息解密失败: {e}")
return Response(content="success", media_type="text/plain")
except Exception as e:
# 其他未知错误,记录日志但仍返回 success
logger.error(f"消息处理未知错误: {e}", exc_info=True)
return Response(content="success", media_type="text/plain")
async def _handle_event(
event: str, from_user_id: str, message_dict: dict
) -> None:
"""处理企微事件消息。
事件类型:
- subscribe: 员工关注应用
- unsubscribe: 员工取消关注
- enter_agent: 员工进入应用
Args:
event: 事件类型
from_user_id: 发送者企微 UserID
message_dict: 完整消息字典
"""
if event == "enter_agent":
logger.info(f"员工进入应用: user_id={from_user_id}")
elif event == "subscribe":
logger.info(f"员工关注应用: user_id={from_user_id}")
elif event == "unsubscribe":
logger.info(f"员工取消关注: user_id={from_user_id}")
else:
logger.info(f"收到事件消息: event={event}, user_id={from_user_id}")
+227
View File
@@ -0,0 +1,227 @@
# =============================================================================
# 企微IT智能服务台 — AI Wingman API 路由
# =============================================================================
# 说明:坐席端 AI 智能副驾驶 API,包含 3 个核心端点:
# 1. POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
# 2. POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
# 3. POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
#
# 所有端点需要坐席认证(get_current_agent
# =============================================================================
import logging
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import dep_wingman_service
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.models.message import Message
from app.services.wingman_service import WingmanService
from app.utils.response import ERR_NOT_FOUND, success_response
# 复用坐席认证依赖
from app.api.agents import get_current_agent
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 辅助函数
# --------------------------------------------------------------------------
async def _validate_conversation(
conversation_id: str,
agent: Agent,
db: AsyncSession,
) -> Conversation:
"""验证会话存在性并返回会话对象。
Args:
conversation_id: 会话ID
agent: 当前坐席
db: 数据库会话
Returns:
Conversation: 会话对象
Raises:
AppException: 会话不存在
"""
stmt = select(Conversation).where(Conversation.id == conversation_id)
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise ERR_NOT_FOUND
return conversation
async def _get_recent_messages(
conversation_id: str,
db: AsyncSession,
limit: int = 20,
) -> list[dict]:
"""获取会话最近的消息历史(转换为字典列表)。
Args:
conversation_id: 会话ID
db: 数据库会话
limit: 获取的消息条数
Returns:
list[dict]: 消息字典列表
"""
stmt = (
select(Message)
.where(Message.conversation_id == conversation_id)
.order_by(Message.created_at.desc())
.limit(limit)
)
result = await db.execute(stmt)
messages = list(result.scalars().all())
# 按时间正序排列(最早的在前)
messages.reverse()
# 转换为字典列表
return [
{
"id": msg.id,
"sender_type": msg.sender_type,
"sender_name": msg.sender_name,
"content": msg.content,
"msg_type": msg.msg_type,
"created_at": msg.created_at.isoformat() if msg.created_at else "",
}
for msg in messages
]
# --------------------------------------------------------------------------
# POST /api/conversations/{conversation_id}/wingman/draft
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/wingman/draft")
async def generate_draft(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
wingman_service: WingmanService = Depends(dep_wingman_service),
):
"""生成 AI 草稿回复。
基于当前会话的消息历史,让 Wingman Agent 生成坐席可以采纳的草稿回复。
Args:
conversation_id: 会话ID
agent: 当前坐席(通过认证依赖注入)
db: 数据库会话
wingman_service: Wingman 服务实例
Returns:
Dict: 统一响应格式,包含草稿内容、置信度和推理说明
"""
# 1. 验证坐席身份 + 会话存在性
await _validate_conversation(conversation_id, agent, db)
# 2. 从数据库读取该会话的消息历史(最近 20 条)
messages = await _get_recent_messages(conversation_id, db, limit=20)
# 3. 调用 WingmanService 生成草稿
result = await wingman_service.generate_draft(
conversation_id=conversation_id,
messages=messages,
db=db,
)
return success_response(data=result)
# --------------------------------------------------------------------------
# POST /api/conversations/{conversation_id}/wingman/summary
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/wingman/summary")
async def generate_summary(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
wingman_service: WingmanService = Depends(dep_wingman_service),
):
"""生成会话自动摘要。
基于完整对话生成结构化摘要,包含问题、原因、解决方案。
通常在结单时调用。
Args:
conversation_id: 会话ID
agent: 当前坐席
db: 数据库会话
wingman_service: Wingman 服务实例
Returns:
Dict: 统一响应格式,包含问题、原因、解决方案
"""
# 1. 验证坐席身份 + 会话存在性
await _validate_conversation(conversation_id, agent, db)
# 2. 从数据库读取该会话的完整消息历史(最多 50 条)
messages = await _get_recent_messages(conversation_id, db, limit=50)
# 3. 调用 WingmanService 生成摘要
result = await wingman_service.generate_summary(
conversation_id=conversation_id,
messages=messages,
)
return success_response(data=result)
# --------------------------------------------------------------------------
# POST /api/conversations/{conversation_id}/wingman/tags
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/wingman/tags")
async def suggest_tags(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
wingman_service: WingmanService = Depends(dep_wingman_service),
):
"""生成自动标签建议。
基于对话内容建议标签分类,包含标签列表、分类和优先级。
Args:
conversation_id: 会话ID
agent: 当前坐席
db: 数据库会话
wingman_service: Wingman 服务实例
Returns:
Dict: 统一响应格式,包含建议标签、分类和优先级
"""
# 1. 验证坐席身份 + 会话存在性
conversation = await _validate_conversation(conversation_id, agent, db)
# 2. 从数据库读取该会话的消息历史(最近 20 条)
messages = await _get_recent_messages(conversation_id, db, limit=20)
# 3. 获取已有标签(用于避免重复建议)
existing_tags = {}
if hasattr(conversation, 'tags') and conversation.tags:
existing_tags = conversation.tags if isinstance(conversation.tags, dict) else {}
# 4. 调用 WingmanService 生成标签建议
result = await wingman_service.suggest_tags(
conversation_id=conversation_id,
messages=messages,
existing_tags=existing_tags,
)
return success_response(data=result)
+278
View File
@@ -0,0 +1,278 @@
# =============================================================================
# 企微IT智能服务台 — WebSocket 端点
# =============================================================================
# 说明:提供 WebSocket 端点,供坐席前端和H5用户端建立长连接,实现实时推送。
# 核心功能:
# 1. 接受坐席的 WebSocket 连接请求(含 token 认证)— /ws/{agent_id}
# 2. 接受H5员工的 WebSocket 连接请求(含 token 认证)— /ws/h5/{employee_id}
# 3. 维持连接,监听客户端消息(主要是心跳 ping)
# 4. 连接断开时自动清理注册信息
# 安全(WS-01):
# 握手时从 query param 取 token → 查 Redis 验证 → 不通过则 close(code=4001)
# 防止未授权用户冒充坐席/员工建立 WS 连接
#
# 端点路径:
# - 坐席端:/ws/{agent_id}?token=xxx
# - H5员工端:/ws/h5/{employee_id}?token=xxx
# 为什么不挂 /api 前缀:WebSocket 不是 REST API,不走 Vite 的 /api 代理配置
# =============================================================================
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.services.ws_manager import manager as ws_manager
from app.services.cache_service import cache_service
logger = logging.getLogger(__name__)
# WebSocket 路由器(不挂 /api 前缀,直接注册在应用根路径)
router = APIRouter()
# 认证失败时的 WebSocket 关闭码
# 4001 = 自定义码,表示"未授权"(4000+ 为应用自定义范围)
WS_CLOSE_UNAUTHORIZED = 4001
@router.websocket("/ws/{agent_id}")
async def websocket_endpoint(
websocket: WebSocket,
agent_id: str,
token: str = Query(default="", description="登录 token,用于 WebSocket 认证"),
) -> None:
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
做什么:
1. 验证 token 有效性(查 Redis
2. 验证 token 与 agent_id 一致性(防冒充)
3. 认证通过后接受连接,注册到 ConnectionManager
4. 进入消息接收循环,处理客户端发送的消息
5. 连接断开时清理注册信息
为什么需要 token 认证(WS-01):
- 之前 /ws/{agent_id} 无任何认证,任何人知道 URL 即可冒充任意坐席
- 攻击者可监听所有消息、发送伪造消息,是 P0 级安全漏洞
- 修复后,必须提供与 agent_id 匹配的有效 token 才能建立连接
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
agent_id: 坐席ID(从 URL 路径参数获取)
token: 登录 token(从 URL query parameter 获取)
"""
# ======================================================================
# WS-01: Token 认证
# ======================================================================
# 步骤1: 检查 token 是否为空
if not token:
# 先 accept 再 close,否则客户端收不到关闭帧
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=缺少token")
return
# 步骤2: 从 Redis 查询 token 对应的坐席信息
# Redis 中存储格式: agent:token:{token} -> agent_user_id
# (与坐席登录 API /api/agents/login 存储格式一致)
try:
stored_agent_id = await cache_service.get(f"agent:token:{token}")
except Exception as e:
# Redis 不可用时必须拒绝连接:token 验证依赖 Redis,无法验证身份
# 如果降级放行,攻击者可在 Redis 故障时用任意 agent_id 冒充坐席
logger.error(f"Redis 查询失败,拒绝 WS 连接: agent_id={agent_id}, error={e}")
await websocket.accept()
await websocket.close(
code=WS_CLOSE_UNAUTHORIZED,
reason="Authentication service unavailable"
)
return
# 步骤3: 验证 token 与 agent_id 一致性
if not stored_agent_id:
# token 不存在(已过期或伪造)
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=token无效或已过期")
return
if stored_agent_id != agent_id:
# token 对应的坐席与请求的 agent_id 不匹配(冒充)
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Token-agent mismatch")
logger.warning(
f"WebSocket 拒绝连接: agent_id={agent_id}, "
f"原因=token对应坐席{stored_agent_id}与请求不匹配"
)
return
# ======================================================================
# 认证通过,建立连接
# ======================================================================
# 注册连接(内部会调用 websocket.accept()
await ws_manager.connect(agent_id, websocket)
logger.info(f"坐席 WebSocket 连接已认证: agent_id={agent_id}")
try:
# 消息接收循环
# 保持连接打开,监听客户端发来的消息
# 即使客户端不发消息,这个循环也必须保持,否则连接会关闭
while True:
# 等待接收客户端消息(阻塞等待)
data = await websocket.receive_json()
# 处理心跳 ping
# 前端每 30 秒发送一次 ping,后端回复 pong
# 作用:检测连接是否存活,防止中间代理(如 Nginx)因超时断开连接
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
logger.debug(f"WebSocket 心跳: agent_id={agent_id}")
# 处理输入指示器 typing 事件
# 前端在用户输入时发送 typing 事件,后端广播给同一会话的其他参与者
elif data.get("type") == "typing":
conversation_id = data.get("conversation_id")
sender_name = data.get("sender_name", agent_id)
if conversation_id:
# 广播给所有坐席(包含 sender_type 和 sender_id
# 前端可据此过滤掉自己的 typing 事件)
await ws_manager.broadcast({
"type": "typing",
"data": {
"conversation_id": conversation_id,
"sender_id": agent_id,
"sender_name": sender_name,
"sender_type": "agent",
}
})
else:
# 未来可扩展处理其他类型的客户端消息
logger.debug(
f"WebSocket 收到未知消息: agent_id={agent_id}, "
f"type={data.get('type', 'unknown')}"
)
except WebSocketDisconnect:
# 客户端主动断开连接(正常行为)
# 清理 ConnectionManager 中的注册信息
ws_manager.disconnect(agent_id)
logger.info(f"坐席断开 WebSocket 连接: agent_id={agent_id}")
except Exception as e:
# 其他异常(如网络错误、JSON 解析错误等)
# 确保注册信息被清理
ws_manager.disconnect(agent_id)
logger.warning(f"WebSocket 异常断开: agent_id={agent_id}, error={e}")
# ==========================================================================
# H5员工 WebSocket 端点
# ==========================================================================
@router.websocket("/ws/h5/{employee_id}")
async def h5_websocket_endpoint(
websocket: WebSocket,
employee_id: str,
token: str = Query(default="", description="H5员工登录 token,用于 WebSocket 认证"),
) -> None:
"""H5员工 WebSocket 端点主循环(含 token 认证)。
做什么:
1. 验证 employee token 有效性(查 Redis
2. 验证 token 与 employee_id 一致性(防冒充)
3. 认证通过后接受连接,注册到 ConnectionManager 的员工连接表
4. 进入消息接收循环,处理心跳 ping
5. 连接断开时清理注册信息
为什么需要 H5 WS 连接:
- H5员工需要实时接收参与者变更事件(新参与者加入、有人退出等)
- 当前仅通过 3 秒轮询获取更新,实时性不足
- WS 推送 + 轮询降级,双通道保证消息可达
认证机制(与坐席端一致):
- Redis 中存储格式: employee:token:{token} -> employee_id
- (与H5登录 API /api/h5/mock-login 存储格式一致)
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
employee_id: 员工企微 UserID(从 URL 路径参数获取)
token: H5员工登录 token(从 URL query parameter 获取)
"""
# ======================================================================
# Token 认证
# ======================================================================
# 步骤1: 检查 token 是否为空
if not token:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
logger.warning(f"H5 WebSocket 拒绝连接: employee_id={employee_id}, 原因=缺少token")
return
# 步骤2: 从 Redis 查询 token 对应的员工信息
# Redis 中存储格式: employee:token:{token} -> employee_id
# (与H5登录 API /api/h5/mock-login 存储格式一致)
try:
stored_employee_id = await cache_service.get(f"employee:token:{token}")
except Exception as e:
# Redis 不可用时必须拒绝连接(与坐席端一致的安全策略)
logger.error(f"Redis 查询失败,拒绝 H5 WS 连接: employee_id={employee_id}, error={e}")
await websocket.accept()
await websocket.close(
code=WS_CLOSE_UNAUTHORIZED,
reason="Authentication service unavailable"
)
return
# 步骤3: 验证 token 与 employee_id 一致性
if not stored_employee_id:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"H5 WebSocket 拒绝连接: employee_id={employee_id}, 原因=token无效或已过期")
return
if stored_employee_id != employee_id:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Token-employee mismatch")
logger.warning(
f"H5 WebSocket 拒绝连接: employee_id={employee_id}, "
f"原因=token对应员工{stored_employee_id}与请求不匹配"
)
return
# ======================================================================
# 认证通过,建立连接
# ======================================================================
# 注册员工连接(内部会调用 websocket.accept()
await ws_manager.connect_employee(employee_id, websocket)
logger.info(f"H5员工 WebSocket 连接已认证: employee_id={employee_id}")
try:
# 消息接收循环
# H5员工端目前只发送心跳 ping,不需要发送 typing 等事件
while True:
data = await websocket.receive_json()
# 处理心跳 ping
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
logger.debug(f"H5 WebSocket 心跳: employee_id={employee_id}")
else:
logger.debug(
f"H5 WebSocket 收到未知消息: employee_id={employee_id}, "
f"type={data.get('type', 'unknown')}"
)
except WebSocketDisconnect:
# 客户端主动断开连接
ws_manager.disconnect_employee(employee_id)
logger.info(f"H5员工断开 WebSocket 连接: employee_id={employee_id}")
except Exception as e:
# 其他异常
ws_manager.disconnect_employee(employee_id)
logger.warning(f"H5 WebSocket 异常断开: employee_id={employee_id}, error={e}")
+146
View File
@@ -0,0 +1,146 @@
# =============================================================================
# 企微IT智能服务台 — 配置管理模块
# =============================================================================
# 说明:使用 pydantic-settings 从环境变量读取所有配置项
# 优先级:环境变量 > .env 文件 > 默认值
# 所有配置项集中管理,避免散落在代码各处
# =============================================================================
from typing import List
import redis.asyncio as aioredis
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""应用配置类。
使用 pydantic-settings 自动从环境变量读取配置值。
支持 .env 文件自动加载(开发环境便利)。
Attributes:
wecom_corp_id: 企业微信企业ID
wecom_agent_id: 企业微信应用AgentId
wecom_secret: 企业微信应用Secret
wecom_token: 企业微信回调Token
wecom_encoding_aes_key: 企业微信回调EncodingAESKey43位)
database_url: PostgreSQL 数据库连接地址
redis_url: Redis 连接地址
backend_host: 后端监听地址
backend_port: 后端监听端口
cors_origins: CORS 允许的源地址(逗号分隔)
"""
# ----------------------------------------------------------------------
# 企微自建应用配置
# ----------------------------------------------------------------------
# 企业ID(在企微管理后台 > 我的企业 > 企业信息 中查看)
wecom_corp_id: str = "ww1234567890abcdef"
# 应用AgentId(在企微管理后台 > 应用管理 > 自建应用 中查看)
wecom_agent_id: str = "1000002"
# 应用Secret(在企微管理后台 > 应用管理 > 自建应用 中查看)
wecom_secret: str = "your-agent-secret"
# 回调Token(在企微管理后台 > 应用管理 > 接收消息 中设置)
wecom_token: str = "your-callback-token"
# 回调EncodingAESKey43位字符串,用于消息加解密)
wecom_encoding_aes_key: str = "your-aes-key-43-characters-long-encoding-key"
# ----------------------------------------------------------------------
# 数据库配置
# ----------------------------------------------------------------------
# PostgreSQL 连接地址
# Docker 环境使用容器名 postgres,本地开发使用 localhost
database_url: str = "postgresql://wecom:wecom_secret@localhost:5432/wecom_it_desk"
# ----------------------------------------------------------------------
# Redis 配置
# ----------------------------------------------------------------------
# Redis 连接地址
# Docker 环境使用容器名 redis,本地开发使用 localhost
redis_url: str = "redis://localhost:6379/0"
# ----------------------------------------------------------------------
# 服务配置
# ----------------------------------------------------------------------
# 后端监听地址(0.0.0.0 表示监听所有网卡)
backend_host: str = "0.0.0.0"
# 后端监听端口
backend_port: int = 8000
# CORS 允许的源地址(逗号分隔的字符串)
cors_origins: str = "http://localhost:5173,http://localhost:5174,http://localhost:5175"
# ----------------------------------------------------------------------
# AI 服务配置(Dify
# ----------------------------------------------------------------------
# Dify API 端点(兼容 OpenAI Chat Completions 格式)
# 必须通过环境变量 DIFY_API_URL 配置,不设置默认值(防止凭据泄露)
dify_api_url: str = ""
# Dify API Key(格式:base_url|app_id|app_name
# 必须通过环境变量 DIFY_API_KEY 配置,不设置默认值(防止凭据泄露)
dify_api_key: str = ""
# Dify API 请求超时(秒),在网络慢时可调大
dify_timeout: int = 30
# ----------------------------------------------------------------------
# AI Wingman 服务配置(Dify Agent 2 — 坐席端辅助)
# ----------------------------------------------------------------------
# 坐席端 Wingman 专用 Dify API 端点(与员工端 Agent 分开)
# 留空则禁用 Wingman 功能(不影响主流程)
dify_wingman_api_url: str = ""
# 坐席端 Wingman Dify API Key(需要新建 Agent 后填入,留空则禁用)
# 格式:base_url|app_id|app_name(与 dify_api_key 相同格式)
dify_wingman_api_key: str = ""
# Wingman API 请求超时(秒)
dify_wingman_timeout: int = 30
# ----------------------------------------------------------------------
# Mock 登录配置(测试阶段使用,跳过企微 OAuth2)
# ----------------------------------------------------------------------
# 是否启用 Mock 登录(默认 false,生产环境必须关闭)
mock_login_enabled: bool = False
# ----------------------------------------------------------------------
# Pydantic-settings 配置
# ----------------------------------------------------------------------
model_config = SettingsConfigDict(
# 自动从 .env 文件加载环境变量
env_file=".env",
# .env 文件编码
env_file_encoding="utf-8",
# 环境变量名大小写不敏感
case_sensitive=False,
# 额外字段不允许(防止拼写错误的配置被忽略)
extra="ignore",
)
@property
def cors_origins_list(self) -> List[str]:
"""将 CORS 源地址字符串解析为列表。
将逗号分隔的字符串(如 "http://a,http://b"
转换为列表(如 ["http://a", "http://b"]),
方便 FastAPI 的 CORSMiddleware 使用。
Returns:
List[str]: CORS 允许的源地址列表
"""
# 去除每项的前后空格,过滤空字符串
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
def create_redis_client(self) -> aioredis.Redis:
"""创建 Redis 异步客户端实例。
自动附加 protocol=2 参数,强制使用 RESP2 协议。
原因:Windows 版 Redis 3.x 不支持 RESP3 协议(HELLO 命令),
而 redis-py 8.0+ 默认使用 RESP3,会导致连接失败。
全项目统一使用此方法创建 Redis 客户端,避免协议不匹配。
Returns:
aioredis.Redis: 配置好的 Redis 异步客户端
"""
return aioredis.from_url(self.redis_url, protocol=2)
# 创建全局配置实例
# 整个应用通过 from app.config import settings 使用同一个实例
settings = Settings()
+146
View File
@@ -0,0 +1,146 @@
# =============================================================================
# 企微IT智能服务台 — 数据库连接与 Session 管理
# =============================================================================
# 说明:使用 SQLAlchemy 2.0 的异步引擎和会话管理,负责:
# 1. 创建异步数据库引擎(懒加载,支持 PostgreSQL 和 SQLite
# 2. 创建异步会话工厂
# 3. 提供 get_db 依赖注入函数(FastAPI 路由中使用)
# 4. 自动建表(SQLite 本地开发时自动创建所有表)
# =============================================================================
import logging
from typing import AsyncGenerator, Optional
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
# 导入配置(读取数据库连接地址)
from app.config import settings
from app.utils.response import AppException
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# 声明式基类(单独定义,不依赖引擎创建)
# ----------------------------------------------------------------------
# 所有模型类都继承自 Base,SQLAlchemy 通过它来检测所有模型定义
# Alembic 也通过 Base.metadata 来生成迁移脚本
# ----------------------------------------------------------------------
class Base(DeclarativeBase):
"""SQLAlchemy 声明式基类。
所有模型类都继承此类,SQLAlchemy 通过它管理所有表的元数据。
"""
pass
# ----------------------------------------------------------------------
# 懒加载引擎和会话工厂
# ----------------------------------------------------------------------
_engine: Optional[object] = None
_async_session_factory: Optional[async_sessionmaker] = None
_tables_created: bool = False # 标记是否已自动建表
def _is_sqlite() -> bool:
"""判断当前数据库 URL 是否为 SQLite。"""
return "sqlite" in settings.database_url.lower()
def _get_engine():
"""懒加载获取数据库引擎。
支持 PostgreSQL 和 SQLite 两种后端:
- PostgreSQL: 使用 asyncpg 异步驱动,带连接池
- SQLite: 使用 aiosqlite 异步驱动,无需连接池
"""
global _engine
if _engine is None:
db_url = settings.database_url
if _is_sqlite():
# SQLite 异步驱动:aiosqlite
# 不需要连接池,SQLite 是单文件数据库
_engine = create_async_engine(
db_url,
echo=False,
)
logger.info(f"使用 SQLite 数据库: {db_url}")
else:
# PostgreSQL 异步驱动:asyncpg
_engine = create_async_engine(
db_url.replace("postgresql://", "postgresql+asyncpg://"),
echo=False,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
logger.info(f"使用 PostgreSQL 数据库: {db_url.split('@')[-1]}")
return _engine
def _get_session_factory() -> async_sessionmaker:
"""懒加载获取会话工厂。"""
global _async_session_factory
if _async_session_factory is None:
_async_session_factory = async_sessionmaker(
_get_engine(),
class_=AsyncSession,
expire_on_commit=False,
)
return _async_session_factory
async def _ensure_tables():
"""自动建表(仅 SQLite 本地开发时使用)。
PostgreSQL 环境应使用 Alembic 迁移管理表结构。
SQLite 环境下直接用 metadata.create_all 创建所有表,省去迁移步骤。
"""
global _tables_created
if _tables_created:
return
_tables_created = True
if _is_sqlite():
# 导入所有模型,确保 Base.metadata 知道所有表
import app.models # noqa: F401
engine = _get_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("SQLite 自动建表完成")
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话的依赖注入函数。
在 FastAPI 路由中通过 Depends(get_db) 注入数据库会话。
使用 async with 确保会话在使用后正确关闭。
使用 try/finally 确保异常时也能回滚和关闭。
Yields:
AsyncSession: 异步数据库会话
"""
# 首次调用时自动建表(SQLite
await _ensure_tables()
# 创建一个新的数据库会话(懒加载会话工厂)
try:
session_factory = _get_session_factory()
except Exception as e:
logger.error(f"数据库连接失败(无法创建会话工厂): {e}")
raise AppException(1006, f"数据库连接失败: {str(e)}")
async with session_factory() as session:
try:
# 将会话交给路由函数使用
yield session
# 路由函数执行成功后提交事务
await session.commit()
except Exception:
# 路由函数执行失败时回滚事务
await session.rollback()
# 重新抛出异常,让 FastAPI 的异常处理器处理
raise
+266
View File
@@ -0,0 +1,266 @@
# =============================================================================
# 企微IT智能服务台 — 统一认证依赖
# =============================================================================
# 说明:提供统一的认证依赖函数,支持:
# 1. get_current_user: 获取当前用户信息(包含角色)
# 2. require_role: 角色验证装饰器
# 3. require_admin: 管理员权限验证
# =============================================================================
import json
import logging
from dataclasses import dataclass
from functools import wraps
from typing import List, Optional
import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import settings
from app.services.token_service import TokenService
logger = logging.getLogger(__name__)
# HTTP Bearer 认证方案
security = HTTPBearer()
@dataclass
class UserInfo:
"""用户信息数据类。
Attributes:
employee_id: 企微 UserID
name: 用户姓名
department: 部门
avatar: 头像URL
roles: 角色列表
current_role: 当前选择的角色
login_source: 登录来源
"""
employee_id: str
name: str
department: str
avatar: str
roles: List[str]
current_role: str
login_source: str
# Redis 连接池(单例)
_redis_pool: Optional[aioredis.Redis] = None
async def get_redis() -> aioredis.Redis:
"""获取 Redis 连接。
Returns:
aioredis.Redis: Redis 异步客户端
"""
global _redis_pool
if _redis_pool is None:
_redis_pool = settings.create_redis_client()
return _redis_pool
# 共享服务实例(用于 wecom_callback.py 等模块)
# 这些函数提供同步获取服务实例的方式,用于非 FastAPI DI 的场景
def get_shared_redis() -> aioredis.Redis:
"""获取 Redis 客户端(同步版本,用于非 async 场景)。
Returns:
aioredis.Redis: Redis 客户端实例
"""
return settings.create_redis_client()
def get_shared_wecom_service():
"""获取 WecomService 共享实例。
Returns:
WecomService: 企微服务实例
"""
from app.services.wecom_service import WecomService
return WecomService(settings.create_redis_client())
def get_shared_ai_handler():
"""获取 AIHandler 共享实例。
Returns:
AIHandler: AI 处理器实例
"""
from app.services.ai_handler import AIHandler
from app.services.ai_service import AIService
return AIHandler(ai_service=AIService())
# FastAPI Depends 函数(用于路由依赖注入)
async def dep_redis() -> aioredis.Redis:
"""Redis 客户端依赖注入。
Returns:
aioredis.Redis: Redis 异步客户端
"""
return await get_redis()
def dep_wecom_service():
"""WecomService 依赖注入。
Returns:
WecomService: 企微服务实例
"""
from app.services.wecom_service import WecomService
return WecomService(settings.create_redis_client())
def dep_ai_handler():
"""AIHandler 依赖注入。
Returns:
AIHandler: AI 处理器实例
"""
from app.services.ai_handler import AIHandler
from app.services.ai_service import AIService
return AIHandler(ai_service=AIService())
def dep_wingman_service():
"""WingmanService 依赖注入。
Returns:
WingmanService: AI Wingman 服务实例
"""
from app.services.wingman_service import WingmanService
return WingmanService()
# 应用生命周期管理函数
async def init_shared_services():
"""初始化共享服务(应用启动时调用)。
创建 Redis 连接池,初始化共享服务实例。
"""
global _redis_pool
_redis_pool = settings.create_redis_client()
logger.info("共享服务初始化完成")
async def cleanup_shared_services():
"""清理共享服务(应用关闭时调用)。
关闭 Redis 连接池。
"""
global _redis_pool
if _redis_pool:
await _redis_pool.close()
_redis_pool = None
logger.info("共享服务清理完成")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> UserInfo:
"""统一认证依赖:从 Token 获取用户信息。
支持新旧两种 Token 格式。
Args:
credentials: HTTP Bearer Token
Returns:
UserInfo: 用户信息
Raises:
HTTPException: Token 无效或已过期
"""
token = credentials.credentials
# 获取 Redis 连接
redis_client = await get_redis()
# 创建 Token 服务
token_service = TokenService(redis_client)
# 获取用户信息
user_info = await token_service.get_user_info(token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期",
headers={"WWW-Authenticate": "Bearer"},
)
return UserInfo(
employee_id=user_info["employee_id"],
name=user_info.get("name", ""),
department=user_info.get("department", ""),
avatar=user_info.get("avatar", ""),
roles=user_info.get("roles", ["user"]),
current_role=user_info.get("current_role", "user"),
login_source=user_info.get("login_source", "portal"),
)
def require_role(*required_roles: str):
"""角色验证装饰器。
检查用户是否拥有指定角色之一。
Args:
*required_roles: 允许的角色列表
Returns:
装饰器函数
Example:
@router.get("/api/admin/dashboard")
@require_role("admin")
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
pass
"""
def decorator(func):
@wraps(func)
async def wrapper(
*args,
current_user: UserInfo = Depends(get_current_user),
**kwargs,
):
# 检查用户是否有任一所需角色
user_roles = set(current_user.roles)
required = set(required_roles)
if not user_roles.intersection(required):
logger.warning(
f"用户 {current_user.employee_id} 角色不足: "
f"拥有 {current_user.roles}, 需要 {required_roles}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要以下角色之一: {', '.join(required_roles)}",
)
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
def require_admin(func):
"""管理员权限验证装饰器。
等同于 @require_role("admin")。
Example:
@router.get("/api/admin/dashboard")
@require_admin
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
pass
"""
return require_role("admin")(func)
+6
View File
@@ -0,0 +1,6 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统集成模块包
# =============================================================================
# 说明:各外部系统的 API 客户端、数据模型、异常定义等
# 当前已实现:火绒终端安全
#
@@ -0,0 +1,3 @@
# =============================================================================
# 企微IT智能服务台 — 火绒终端安全集成模块包
# =============================================================================
+658
View File
@@ -0,0 +1,658 @@
# =============================================================================
# 企微IT智能服务台 — 火绒终端安全 API 客户端
# =============================================================================
# 说明:封装火绒API的签名、请求、响应处理
# 核心功能:
# 1. HRESS 签名实现(Authorization Header 方式)
# 2. 统一请求封装(超时、重试、异常处理)
# 3. P0 接口:终端列表 _list / 终端详情 _info2 / 高危漏洞 _leak / 病毒事件 _virus_events
# 4. P1 接口:终端隔离/解除 _create(netctrl) / 快速扫描 / 在线终端查询
# 签名算法(来自火绒官方API文档 v1):
# Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature
# Signature = urlencode(base64(hmac-sha1(AccessKeySecret,
# AccessKeyId + "\n" + Expires + "\n" + HTTP-METHOD + "\n"
# + Content-MD5 + "\n" + CanonicalizedResource)))
# CanonicalizedResource = API路径(无前导/),含排序后的查询参数
# Content-MD5 = base64(md5_digest(body_bytes)) — RFC2616
# 使用方式:
# client = HuorongClient(access_key_id="...", access_key_secret="...", base_url="...")
# terminals = await client.list_terminals()
# =============================================================================
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import httpx
from .exceptions import (
HuorongApiError,
HuorongAuthError,
HuorongConnectionError,
HuorongError,
)
from .models import (
HuorongApiResponse,
TerminalBasicInfo,
TerminalDetailV2,
TerminalLeakInfo,
VirusEventStats,
VirusHandleResult,
)
logger = logging.getLogger(__name__)
# 默认请求超时(秒)— 火绒内网响应通常在1秒内,3秒足够兜底
# 注意:_virus_events 查询全部终端时可能较慢,需要更长超时
DEFAULT_TIMEOUT = 10.0
# 默认分页大小
DEFAULT_PAGE_SIZE = 20
# 签名有效期(秒)— 请求签名中的 Expires 字段
SIGN_EXPIRES_SECONDS = 300
class HuorongClient:
"""火绒终端安全 API 客户端。
封装了火绒API的签名认证、请求发送和响应解析。
所有方法均为异步(async),使用 httpx.AsyncClient 发送请求。
签名方式:HRESS Authorization Header
参考:火绒终端安全管理系统API说明文档 v1
Attributes:
access_key_id: 火绒 AccessKey ID(控制中心显示为 Secret ID
access_key_secret: 火绒 AccessKey Secret(控制中心显示为 Secret Key
base_url: 火绒API内网地址(如 http://huorong.oa.servyou-it.com:8080
timeout: 请求超时秒数
"""
def __init__(
self,
access_key_id: str,
access_key_secret: str,
base_url: str,
timeout: float = DEFAULT_TIMEOUT,
):
"""初始化火绒API客户端。
Args:
access_key_id: 火绒 AccessKey ID(控制中心显示为 Secret ID
access_key_secret: 火绒 AccessKey Secret(控制中心显示为 Secret Key
base_url: 火绒API内网地址(不含尾部斜杠)
timeout: 请求超时秒数,默认3秒
"""
self.access_key_id = access_key_id
self.access_key_secret = access_key_secret
# 确保 base_url 不以 / 结尾,拼接路径时统一加 /
self.base_url = base_url.rstrip("/")
self.timeout = timeout
# ======================================================================
# 签名实现(HRESS Authorization Header 方式)
# ======================================================================
def _compute_content_md5(self, body_bytes: bytes) -> str:
"""计算请求体的 Content-MD5RFC2616)。
算法步骤:
1. 计算请求体的 MD5 二进制摘要(128位)
2. 对二进制摘要进行 base64 编码
注意:不是对32位十六进制字符串编码,而是对原始二进制摘要编码。
Args:
body_bytes: 请求体的字节内容
Returns:
str: base64 编码的 MD5 摘要
"""
md5_digest = hashlib.md5(body_bytes).digest()
return base64.b64encode(md5_digest).decode("utf-8")
def _build_canonicalized_resource(self, path: str) -> str:
"""构建 CanonicalizedResource。
根据火绒API文档:
1. 将 CanonicalizedResource 置为空字符串
2. 设置要访问的资源路径(去掉前导 /),如 "api/clnts/_list"
3. 如果请求包含子资源(查询参数),按字典序排列,
以 & 为分隔符生成子资源字符串,末尾添加 ? 和子资源字符串
示例:
- /api/clnts/_list → "api/clnts/_list"
- /api/group/_info?group_id=1 → "api/group/_info?group_id=1"
Args:
path: 请求路径(如 /api/clnts/_list
Returns:
str: CanonicalizedResource 字符串
"""
# 去掉前导 /
return path.lstrip("/")
def _sign_request(
self,
method: str,
path: str,
body_bytes: bytes = b"",
) -> Dict[str, str]:
"""生成火绒API请求签名(HRESS Authorization Header 方式)。
签名算法(来自火绒官方API文档):
┌─────────────────────────────────────────────────────────────────┐
│ Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature │
│ │
│ Signature = urlencode(base64(hmac-sha1(AccessKeySecret, │
│ AccessKeyId + "\\n"
│ + Expires + "\\n"
│ + HTTP-METHOD + "\\n"
│ + Content-MD5 + "\\n"
│ + CanonicalizedResource))) │
└─────────────────────────────────────────────────────────────────┘
其中:
- AccessKeyId: 标识用户身份
- Expires: Unix 时间戳,签名过期时间(当前时间 + 300秒)
- HTTP-METHOD: POST(火绒API统一使用 POST
- Content-MD5: 请求体的 RFC2616 MD5base64编码)
- CanonicalizedResource: API资源路径(去掉前导/)
Args:
method: HTTP方法(POST
path: 请求路径(如 /api/clnts/_list
body_bytes: 请求体字节内容
Returns:
Dict[str, str]: 包含 Authorization 和 Content-Type 的 Header 字典
"""
# 1. 计算过期时间(Unix时间戳)
expires = str(int(time.time()) + SIGN_EXPIRES_SECONDS)
# 2. 计算 Content-MD5RFC2616: MD5 二进制摘要 → base64
content_md5 = self._compute_content_md5(body_bytes) if body_bytes else ""
# 3. 构建 CanonicalizedResource(去掉前导 /
canonicalized_resource = self._build_canonicalized_resource(path)
# 4. 构建签名字符串
string_to_sign = (
self.access_key_id + "\n"
+ expires + "\n"
+ method + "\n"
+ content_md5 + "\n"
+ canonicalized_resource
)
# 5. HMAC-SHA1 签名 → base64 编码 → URL 编码
signature_raw = hmac.new(
self.access_key_secret.encode("utf-8"), # 密钥
string_to_sign.encode("utf-8"), # 待签名字符串
hashlib.sha1, # 算法
).digest()
signature_b64 = base64.b64encode(signature_raw).decode("utf-8")
signature_encoded = quote(signature_b64, safe="")
# 6. 拼接 Authorization Header
# 格式: "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature
authorization = f"HRESS{self.access_key_id}:{expires}:{signature_encoded}"
return {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
}
# ======================================================================
# 通用请求方法
# ======================================================================
async def _request(
self,
path: str,
body: Optional[Dict[str, Any]] = None,
) -> HuorongApiResponse:
"""发送签名请求到火绒API。
统一处理:
1. HRESS 签名 Authorization Header 生成
2. HTTP请求发送(POST,超时控制)
3. 响应解析和错误码处理
4. 异常分类(认证/连接/API业务错误)
Args:
path: API路径(如 /api/clnts/_list
body: 请求体字典(可选)
Returns:
HuorongApiResponse: 火绒API响应
Raises:
HuorongConnectionError: 网络不通或超时
HuorongAuthError: 签名验证失败
HuorongApiError: 火绒API返回业务错误
"""
# 构建完整URL
url = f"{self.base_url}{path}"
# 序列化请求体为字节(签名基于字节内容)
body_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8") if body else b"{}"
# 生成签名 Header
headers = self._sign_request("POST", path, body_bytes)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
logger.debug(
f"火绒API请求: POST {url}\n"
f" AccessKeyID: {self.access_key_id}\n"
f" Path: {path}\n"
f" Body: {body_bytes[:200].decode('utf-8', errors='replace')}\n"
f" Authorization: {headers.get('Authorization', 'N/A')[:60]}..."
)
response = await client.post(url, headers=headers, content=body_bytes)
# HTTP层面错误
if response.status_code == 401:
raise HuorongAuthError()
if response.status_code != 200:
raise HuorongApiError(
code=response.status_code,
message=f"HTTP {response.status_code}: {response.text[:200]}",
)
# 解析JSON响应
resp_data = response.json()
api_resp = HuorongApiResponse(**resp_data)
# 火绒业务错误码处理
# 官方文档定义的错误码:
# - errno=0: 成功
# - errno=1: 认证失败
# - errno=2: 参数错误
# - errno=3: 服务器内部错误
# - errno=4: API未授权
if api_resp.errcode != 0:
if api_resp.errcode == 1 or api_resp.errcode in (401, 403):
raise HuorongAuthError(f"认证/权限失败: {api_resp.errmsg}")
if api_resp.errcode == 4:
raise HuorongApiError(
code=api_resp.errcode,
message=f"API未授权: {api_resp.errmsg}",
)
raise HuorongApiError(
code=api_resp.errcode,
message=api_resp.errmsg,
)
return api_resp
except httpx.TimeoutException:
raise HuorongConnectionError(f"火绒API请求超时({self.timeout}秒): {url}")
except httpx.ConnectError:
raise HuorongConnectionError(f"无法连接火绒服务器: {url}")
except (HuorongAuthError, HuorongApiError, HuorongConnectionError):
# 已分类异常,直接向上抛出
raise
except Exception as e:
# 未预期异常,包装为通用错误
logger.error(f"火绒API未预期异常: {type(e).__name__}: {e}")
raise HuorongError(code=-1, message=f"火绒API调用异常: {e}")
# ======================================================================
# P0 接口:查询能力
# ======================================================================
async def list_terminals(
self,
group_id: Optional[str] = None,
page: int = 1,
per_page: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""查询终端基本信息列表。
火绒API: POST /api/clnts/_list
官方参数: limit(每页条数, 默认15, 最大200) + offset(起始索引, 默认0)
本方法将 page/per_page 转换为 limit/offset,保持外部接口一致。
Args:
group_id: 分组ID(可选,不传则查全部分组)
page: 页码(从1开始,内部转换为offset)
per_page: 每页条数(内部转换为limit)
Returns:
Dict: 包含 total(总数) 和 items(TerminalBasicInfo列表)
"""
# 火绒API使用 limit/offset 分页,不是 page/per_page
limit = min(per_page, 200) # 火绒限制最大200
offset = (page - 1) * limit
body: Dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if group_id:
body["group_id"] = int(group_id)
resp = await self._request("/api/clnts/_list", body)
# 解析响应数据
data = resp.data or {}
raw_items = data.get("list", [])
total = data.get("total", 0)
items = [TerminalBasicInfo(**item) for item in raw_items]
return {
"total": total,
"items": items,
}
async def get_terminal_detail(
self,
client_id: str,
optional_fields: Optional[List[str]] = None,
) -> TerminalDetailV2:
"""获取终端详细信息v2。
火绒API: POST /api/clnts/_info2
用途:获取终端硬件/软件/资产/网络配置等详细信息
Args:
client_id: 终端唯一ID
optional_fields: 需要返回的可选信息块
可选值: hardware, software, assets, netconf
默认全部返回
Returns:
TerminalDetailV2: 终端详细信息
"""
if optional_fields is None:
optional_fields = ["hardware", "software", "assets", "netconf"]
body = {
"client_id": client_id,
"optional_fields": optional_fields,
}
resp = await self._request("/api/clnts/_info2", body)
data = resp.data or {}
return TerminalDetailV2(**data)
async def list_terminal_leaks(
self,
group_id: Optional[str] = None,
page: int = 1,
per_page: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""查询存在高危漏洞未修复的终端。
火绒API: POST /api/clnts/_leak
官方参数: limit(每页条数, 默认15, 最大200) + offset(起始索引, 默认0)
说明:返回的是"存在高危漏洞的终端列表",不是漏洞详情。
每条记录是终端信息,字段名与 _list 不同:
- cid (非 client_id)
- hostname (非 computer_name)
- ip_addr (非 local_ip)
- stat (1=离线,2=在线,3=异常,非 is_online 布尔值)
外层有 all_client(终端总数)和 risk_client(高危终端数)统计。
Args:
group_id: 分组ID(可选,不传则查全部分组)
page: 页码(从1开始,内部转换为offset)
per_page: 每页条数(内部转换为limit)
Returns:
Dict: 包含 total(高危终端总数), risk_client(高危终端数),
all_client(全部终端数) 和 items(TerminalLeakInfo列表)
"""
# 火绒API使用 limit/offset 分页
limit = min(per_page, 200)
offset = (page - 1) * limit
body: Dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if group_id:
body["group_id"] = int(group_id)
resp = await self._request("/api/clnts/_leak", body)
# 解析响应数据
data = resp.data or {}
raw_items = data.get("list", [])
# _leak 不返回 total,但有 all_client 和 risk_client 统计
all_client = data.get("all_client", 0)
risk_client = data.get("risk_client", 0)
items = [TerminalLeakInfo(**item) for item in raw_items]
return {
"total": risk_client, # 高危终端总数 = risk_client
"all_client": all_client, # 全部终端数
"risk_client": risk_client, # 高危终端数
"items": items,
}
async def get_virus_events(
self,
client_id: Optional[str] = None,
group_id: Optional[str] = None,
query_type: int = 2,
begin_time: Optional[int] = None,
end_time: Optional[int] = None,
page: int = 1,
per_page: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""查询终端病毒事件统计。
火绒API: POST /api/clnts/_virus_events
官方参数:
- type: 查询类型(必填)
0=使用终端唯一标识查询(client_id字段必填)
1=使用分组ID查询(group_id字段必填)
2=查询全部终端日志(client_id和group_id字段可忽略)
- client_id: 终端唯一标识
- group_id: 分组ID
- begin_time/end_time: 日志范围时间(Unix时间戳,默认全部时间)
- limit/offset: 分页参数
说明:返回终端维度的病毒日志统计,含 count(总数) 和
result{success/fail/ignored/trusted}(处理结果明细)。
Args:
client_id: 终端唯一IDtype=0时必填)
group_id: 分组IDtype=1时必填)
query_type: 查询类型,默认2(查全部)
begin_time: 日志开始时间(Unix时间戳,可选)
end_time: 日志结束时间(Unix时间戳,可选)
page: 页码(从1开始,内部转换为offset)
per_page: 每页条数(内部转换为limit)
Returns:
Dict: 包含 total(总数) 和 items(VirusEventStats列表)
"""
# 火绒API使用 limit/offset 分页
limit = min(per_page, 200)
offset = (page - 1) * limit
body: Dict[str, Any] = {
"type": query_type,
"limit": limit,
"offset": offset,
}
# 根据查询类型添加可选参数
if query_type == 0 and client_id:
body["client_id"] = client_id
if query_type in (0, 1) and group_id:
body["group_id"] = int(group_id)
# 时间范围过滤
if begin_time:
body["begin_time"] = begin_time
if end_time:
body["end_time"] = end_time
resp = await self._request("/api/clnts/_virus_events", body)
data = resp.data or {}
raw_items = data.get("list", [])
total = data.get("total", 0)
items = [VirusEventStats(**item) for item in raw_items]
return {
"total": total,
"items": items,
}
# ======================================================================
# P1 接口:控制能力
# ======================================================================
async def isolate_terminal(
self,
client_ids: List[str],
) -> Dict[str, Any]:
"""隔离终端(断网)。
火绒API: POST /api/task/_create (type=netctrl, net_isolation=true)
安全等级: 🔴 高危操作,调用方必须确保:
1. 仅 admin 角色可调用
2. 已完成二次确认
3. 已记录操作原因
Args:
client_ids: 目标终端ID列表
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": "netctrl",
"net_isolation": True,
"clients": client_ids,
}
resp = await self._request("/api/task/_create", body)
logger.warning(f"火绒终端隔离操作: client_ids={client_ids}")
return resp.data or {}
async def unisolate_terminal(
self,
client_ids: List[str],
) -> Dict[str, Any]:
"""解除终端隔离(恢复网络)。
火绒API: POST /api/task/_create (type=netctrl, net_isolation=false)
Args:
client_ids: 目标终端ID列表
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": "netctrl",
"net_isolation": False,
"clients": client_ids,
}
resp = await self._request("/api/task/_create", body)
logger.info(f"火绒终端解除隔离: client_ids={client_ids}")
return resp.data or {}
async def create_scan_task(
self,
client_ids: List[str],
scan_type: str = "quick_scan",
) -> Dict[str, Any]:
"""创建终端扫描任务。
火绒API: POST /api/task/_create
扫描类型: quick_scan(快速扫描) / full_scan(全盘扫描) / custom_scan(自定义扫描)
Args:
client_ids: 目标终端ID列表
scan_type: 扫描类型,默认快速扫描
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": scan_type,
"clients": client_ids,
}
resp = await self._request("/api/task/_create", body)
logger.info(f"火绒终端扫描任务: type={scan_type}, client_ids={client_ids}")
return resp.data or {}
async def send_notification(
self,
client_ids: List[str],
content: str,
) -> Dict[str, Any]:
"""向终端发送通知。
火绒API: POST /api/task/_create (type=message)
Args:
client_ids: 目标终端ID列表
content: 通知内容
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": "message",
"clients": client_ids,
"content": content,
}
resp = await self._request("/api/task/_create", body)
logger.info(f"火绒终端通知: client_ids={client_ids}, content={content[:50]}")
return resp.data or {}
# ======================================================================
# 测试连接
# ======================================================================
async def test_connection(self) -> Dict[str, Any]:
"""测试火绒API连接是否正常。
使用 _list 接口(page=1, per_page=1)进行轻量级连接测试,
验证签名是否正确、网络是否可达。
Returns:
Dict: 包含 success(bool) 和 message(str)
"""
try:
result = await self.list_terminals(page=1, per_page=1)
return {
"success": True,
"message": f"连接成功,共 {result.get('total', 0)} 个终端",
"total_terminals": result.get("total", 0),
}
except HuorongAuthError as e:
return {
"success": False,
"message": f"认证失败: {e.message}",
}
except HuorongConnectionError as e:
return {
"success": False,
"message": f"连接失败: {e.message}",
}
except HuorongError as e:
return {
"success": False,
"message": f"测试失败: {e.message}",
}
@@ -0,0 +1,87 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成配置管理
# =============================================================================
# 说明:从系统配置表(system_configs)读取火绒 AccessKey/Secret/BaseUrl
# 构建火绒API客户端实例
# =============================================================================
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_config import SystemConfig
from .client import HuorongClient
from .exceptions import HuorongConfigError
logger = logging.getLogger(__name__)
# 火绒配置在 system_configs 表中的 key 前缀
HUORONG_CONFIG_PREFIX = "integration_huorong_"
async def get_huorong_client(db: AsyncSession) -> HuorongClient:
"""从系统配置表构建火绒API客户端。
读取 integration_huorong_ 前缀的配置项,构建 HuorongClient 实例。
如果任何必填配置缺失,抛出 HuorongConfigError。
Args:
db: 数据库会话
Returns:
HuorongClient: 已配置的火绒API客户端实例
Raises:
HuorongConfigError: AccessKey ID/Secret/Base URL 任一缺失
"""
# 读取三个必填配置
result = await db.execute(
select(SystemConfig).where(
SystemConfig.config_key.startswith(HUORONG_CONFIG_PREFIX)
)
)
configs = list(result.scalars().all())
# 构建 key→value 映射
config_map = {cfg.config_key: cfg.config_value for cfg in configs}
access_key_id = config_map.get(f"{HUORONG_CONFIG_PREFIX}access_key_id", "")
access_key_secret = config_map.get(f"{HUORONG_CONFIG_PREFIX}access_key_secret", "")
base_url = config_map.get(f"{HUORONG_CONFIG_PREFIX}base_url", "")
# 校验必填项
if not access_key_id or not access_key_secret or not base_url:
missing = []
if not access_key_id:
missing.append("AccessKey ID")
if not access_key_secret:
missing.append("AccessKey Secret")
if not base_url:
missing.append("Base URL")
raise HuorongConfigError(
f"火绒集成配置不完整,缺失: {', '.join(missing)},请先在管理后台完成配置"
)
return HuorongClient(
access_key_id=access_key_id,
access_key_secret=access_key_secret,
base_url=base_url,
)
async def is_huorong_configured(db: AsyncSession) -> bool:
"""检查火绒集成是否已完整配置。
Args:
db: 数据库会话
Returns:
bool: 三项配置均存在且非空时返回 True
"""
try:
client = await get_huorong_client(db)
return bool(client.access_key_id and client.access_key_secret and client.base_url)
except HuorongConfigError:
return False
@@ -0,0 +1,63 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成自定义异常
# =============================================================================
# 说明:火绒API调用中可能抛出的各种异常类型
# 包含:认证错误、连接超时、API错误码等
# =============================================================================
class HuorongError(Exception):
"""火绒集成基础异常。
所有火绒相关异常的父类,便于统一捕获处理。
Attributes:
code: 错误码(火绒API返回的errcode,或自定义错误码)
message: 错误描述
"""
def __init__(self, code: int = -1, message: str = "火绒API调用失败"):
self.code = code
self.message = message
super().__init__(f"[HuorongError:{code}] {message}")
class HuorongAuthError(HuorongError):
"""火绒认证失败异常。
场景:AccessKey ID/Secret 无效、签名校验失败、权限不足
火绒API返回 errcode=401 或签名相关错误时抛出。
"""
def __init__(self, message: str = "火绒API认证失败,请检查AccessKey配置"):
super().__init__(code=401, message=message)
class HuorongConnectionError(HuorongError):
"""火绒连接失败异常。
场景:内网地址不通、超时、DNS解析失败
"""
def __init__(self, message: str = "无法连接火绒服务器,请检查网络和Base URL配置"):
super().__init__(code=502, message=message)
class HuorongConfigError(HuorongError):
"""火绒配置缺失异常。
场景:AccessKey ID/Secret/Base URL 未在系统配置中设置
"""
def __init__(self, message: str = "火绒集成未配置,请先在管理后台设置AccessKey和Base URL"):
super().__init__(code=400, message=message)
class HuorongApiError(HuorongError):
"""火绒API业务错误。
场景:火绒API返回非0 errcode(如参数错误、终端不存在等)
"""
def __init__(self, code: int, message: str):
super().__init__(code=code, message=message)
+373
View File
@@ -0,0 +1,373 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成数据模型
# =============================================================================
# 说明:火绒API请求/响应的 Pydantic 数据模型
# 包含:终端信息、漏洞信息、病毒事件、任务下发等
# =============================================================================
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, model_validator
# ==========================================================================
# 通用响应模型
# ==========================================================================
class HuorongApiResponse(BaseModel):
"""火绒API统一响应模型。
火绒所有API返回格式一致(官方API文档 v1):
成功时: { "errno": 0, "errmsg": "", "data": { ... } }
失败时: { "errno": 1, "errmsg": "Authentication failed" }
官方错误码定义:
- errno=0: 成功
- errno=1: 认证失败
- errno=2: 参数错误
- errno=3: 服务器内部错误
- errno=4: API未授权
注意:火绒API始终使用 errno(不是 errcode)。
使用 model_validator 在验证前将 errno 归一化为 errcode
保持内部代码统一使用 errcode 字段。
Attributes:
errcode: 错误码,0表示成功(从 errno 归一化而来)
errmsg: 错误描述(成功时为空字符串)
data: 业务数据(成功时非None)
"""
@model_validator(mode='before')
@classmethod
def normalize_error_fields(cls, data: Any) -> Any:
"""将火绒API返回的 errno 字段归一化为 errcode。
火绒API在认证失败等错误场景下返回 errno 而非 errcode
此验证器在 Pydantic 字段校验前将 errno 转换为 errcode
统一后续处理逻辑。
Args:
data: 原始输入数据(通常为dict)
Returns:
归一化后的数据
"""
if isinstance(data, dict) and 'errno' in data and 'errcode' not in data:
data['errcode'] = data.pop('errno')
return data
errcode: int = Field(..., description="错误码,0=成功")
errmsg: str = Field(default="ok", description="错误描述")
data: Optional[Any] = Field(default=None, description="业务数据")
# ==========================================================================
# 终端基本信息 — /api/clnts/_list 返回
# ==========================================================================
class TerminalBasicInfo(BaseModel):
"""终端基本信息(_list 接口返回的每条记录)。
字段名严格按照火绒API文档实际返回值定义。
注意:API返回的字段名与之前猜测不同,已根据官方文档修正。
Attributes:
id: 内部数据库ID
client_id: 终端唯一ID(40位十六进制字符串,用于所有任务下发)
client_name: 客户端名称
computer_name: 计算机名
local_ip: 本地IP
connect_ip: 连接IP(客户端连接控制中心使用的IP)
mac: MAC地址
group_id: 分组ID
os_version: 操作系统版本
version: 火绒客户端版本
definitions: 病毒库更新时间
is_online: 在线状态
last_connect_time: 最后连接时间(Unix时间戳)
last_seen_time: 最后可见时间(Unix时间戳)
first_appear_time: 首次出现时间(Unix时间戳)
"""
id: Optional[int] = Field(default=None, description="内部数据库ID")
client_id: str = Field(..., description="终端唯一ID")
client_name: str = Field(default="", description="客户端名称")
computer_name: str = Field(default="", description="计算机名")
local_ip: str = Field(default="", description="本地IP")
connect_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
group_id: Optional[Any] = Field(default=None, description="分组IDint或str")
os_version: str = Field(default="", description="操作系统版本")
version: str = Field(default="", description="火绒客户端版本")
definitions: str = Field(default="", description="病毒库更新时间")
is_online: bool = Field(default=False, description="在线状态")
last_connect_time: Optional[int] = Field(default=None, description="最后连接时间")
last_seen_time: Optional[int] = Field(default=None, description="最后可见时间")
first_appear_time: Optional[int] = Field(default=None, description="首次出现时间")
class TerminalListRequest(BaseModel):
"""终端列表查询请求。
Attributes:
group_id: 分组ID(可选,不传则查全部分组)
page: 页码(从1开始)
per_page: 每页条数
"""
group_id: Optional[str] = Field(default=None, description="分组ID")
page: int = Field(default=1, ge=1, description="页码")
per_page: int = Field(default=20, ge=1, le=100, description="每页条数")
# ==========================================================================
# 终端详细信息v2 — /api/clnts/_info2 返回
# ==========================================================================
class HardwareInfo(BaseModel):
"""终端硬件信息。
Attributes:
cpu: CPU信息
memory: 内存信息
disk: 磁盘信息
motherboard: 主板信息
network_card: 网卡信息
"""
cpu: str = Field(default="", description="CPU信息")
memory: str = Field(default="", description="内存信息")
disk: str = Field(default="", description="磁盘信息")
motherboard: str = Field(default="", description="主板信息")
network_card: str = Field(default="", description="网卡信息")
class SoftwareInfo(BaseModel):
"""已安装软件条目。
Attributes:
name: 软件名称
version: 版本号
publisher: 发布者
"""
name: str = Field(default="", description="软件名称")
version: str = Field(default="", description="版本号")
publisher: str = Field(default="", description="发布者")
class AssetInfo(BaseModel):
"""资产信息。
Attributes:
asset_tag: 资产标签
serial_number: 序列号
"""
asset_tag: str = Field(default="", description="资产标签")
serial_number: str = Field(default="", description="序列号")
class NetworkConfig(BaseModel):
"""网络配置信息。
Attributes:
ip: IP地址
gateway: 网关
dns: DNS服务器
adapter_info: 网卡适配器信息
"""
ip: str = Field(default="", description="IP地址")
gateway: str = Field(default="", description="网关")
dns: str = Field(default="", description="DNS服务器")
adapter_info: str = Field(default="", description="网卡适配器信息")
class TerminalDetailV2(BaseModel):
"""终端详细信息v2(_info2 接口返回)。
通过 optional_fields 参数指定需要返回的信息块:
- hardware: 硬件信息
- software: 已安装软件
- assets: 资产信息
- netconf: 网络配置
Attributes:
client_id: 终端唯一ID
computer_name: 计算机名
hardware: 硬件信息(可选)
software: 已安装软件列表(可选)
assets: 资产信息(可选)
netconf: 网络配置(可选)
"""
client_id: str = Field(..., description="终端唯一ID")
computer_name: str = Field(default="", description="计算机名")
hardware: Optional[HardwareInfo] = Field(default=None, description="硬件信息")
software: Optional[List[SoftwareInfo]] = Field(default=None, description="已安装软件")
assets: Optional[AssetInfo] = Field(default=None, description="资产信息")
netconf: Optional[NetworkConfig] = Field(default=None, description="网络配置")
class TerminalDetailRequest(BaseModel):
"""终端详细信息查询请求。
Attributes:
client_id: 终端唯一ID
optional_fields: 需要返回的可选信息块列表
"""
client_id: str = Field(..., description="终端唯一ID")
optional_fields: List[str] = Field(
default_factory=lambda: ["hardware", "software", "assets", "netconf"],
description="可选信息块: hardware/software/assets/netconf",
)
# ==========================================================================
# 漏洞信息 — /api/clnts/_leak 返回
# 说明:_leak 接口返回的是"存在高危漏洞未修复的终端列表",
# 每条记录是终端信息(非漏洞详情),API不返回具体漏洞CVE列表。
# 外层还有 all_client(终端总数)和 risk_client(高危终端数)统计。
# ==========================================================================
class TerminalLeakInfo(BaseModel):
"""存在高危漏洞的终端信息(_leak 接口返回的每条记录)。
注意:_leak 返回的是终端维度数据,不是漏洞维度。
字段名严格按照火绒API文档实际返回值定义。
与 _list 接口的字段名不同!
Attributes:
cid: 终端唯一ID_leak 中叫 cid_list 中叫 client_id
hostname: 计算机名(_leak 中叫 hostname_list 中叫 computer_name
client_name: 终端名称
group_name: 分组名称
group_id: 分组ID
ip_addr: 本地IP_leak 中叫 ip_addr_list 中叫 local_ip
call_ip: 连接IP_leak 中叫 call_ip_list 中叫 connect_ip
mac: MAC地址
osver: 操作系统版本(_leak 中叫 osver_list 中叫 os_version
os_type: 终端类型(如 Windows
prodver: 火绒客户端版本(_leak 中叫 prodver_list 中叫 version
virdb: 病毒库版本(Unix时间戳,_leak 中叫 virdb_list 中叫 definitions
stat: 在线状态码(1=离线, 2=在线, 3=异常,_list 中是 is_online 布尔值)
"""
cid: str = Field(..., description="终端唯一ID")
hostname: str = Field(default="", description="计算机名")
client_name: str = Field(default="", description="终端名称")
group_name: str = Field(default="", description="分组名称")
group_id: Optional[Any] = Field(default=None, description="分组ID")
ip_addr: str = Field(default="", description="本地IP")
call_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
osver: str = Field(default="", description="操作系统版本")
os_type: str = Field(default="", description="终端类型")
prodver: str = Field(default="", description="火绒客户端版本")
virdb: Optional[Any] = Field(default=None, description="病毒库版本(Unix时间戳)")
stat: int = Field(default=1, description="在线状态码: 1=离线 2=在线 3=异常")
# ==========================================================================
# 病毒事件 — /api/clnts/_virus_events 返回
# 说明:_virus_events 返回终端维度的病毒日志统计,
# 含总数(count)和4种处理结果(result)的明细。
# 请求需指定 type: 0=按client_id查, 1=按group_id查, 2=查全部
# ==========================================================================
class VirusHandleResult(BaseModel):
"""病毒事件处理结果统计。
Attributes:
success: 处理成功数
fail: 处理失败数
ignored: 暂不处理数
trusted: 已信任数
"""
success: int = Field(default=0, description="处理成功数")
fail: int = Field(default=0, description="处理失败数")
ignored: int = Field(default=0, description="暂不处理数")
trusted: int = Field(default=0, description="已信任数")
class VirusEventStats(BaseModel):
"""终端病毒事件统计(_virus_events 接口返回的每条记录)。
字段名严格按照火绒API文档实际返回值定义。
与 _list 接口的字段名基本一致。
Attributes:
group_id: 分组ID
client_id: 终端唯一ID
client_name: 终端名称
computer_name: 计算机名
local_ip: 本地IP
connect_ip: 连接IP
mac: MAC地址
count: 病毒日志总数
result: 处理结果统计(success/fail/ignored/trusted
"""
group_id: Optional[Any] = Field(default=None, description="分组ID")
client_id: str = Field(..., description="终端唯一ID")
client_name: str = Field(default="", description="终端名称")
computer_name: str = Field(default="", description="计算机名")
local_ip: str = Field(default="", description="本地IP")
connect_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
count: int = Field(default=0, description="病毒日志总数")
result: Optional[VirusHandleResult] = Field(default=None, description="处理结果统计")
# ==========================================================================
# 终端任务 — /api/task/_create
# ==========================================================================
class TaskCreateRequest(BaseModel):
"""终端任务创建请求。
支持的任务类型:
- quick_scan: 快速扫描
- full_scan: 全盘扫描
- custom_scan: 自定义扫描
- netctrl: 终端隔离/解除
- message: 发送通知
Attributes:
task_type: 任务类型
client_ids: 目标终端ID列表
net_isolation: 是否隔离(仅 netctrl 类型有效)
message_content: 通知内容(仅 message 类型有效)
"""
task_type: str = Field(..., description="任务类型: quick_scan/full_scan/custom_scan/netctrl/message")
client_ids: List[str] = Field(..., min_length=1, description="目标终端ID列表")
net_isolation: Optional[bool] = Field(default=None, description="是否隔离(仅netctrl类型)")
message_content: Optional[str] = Field(default=None, description="通知内容(仅message类型)")
# ==========================================================================
# 终端安全画像(聚合模型,供前端直接使用)
# ==========================================================================
class TerminalSecurityProfile(BaseModel):
"""终端安全画像(聚合模型)。
将终端基本信息+安全状态聚合成一个模型,供坐席端直接展示。
Attributes:
client_id: 终端唯一ID
computer_name: 计算机名
ip: 本地IP
mac: MAC地址
os_version: 操作系统版本
is_online: 在线状态
group_name: 分组名称
hardware: 硬件概要
high_risk_leaks: 高危漏洞数
uncleaned_virus: 未处理病毒事件数
security_score: 安全评分(0-100,综合漏洞+病毒+在线状态)
"""
client_id: str = Field(..., description="终端唯一ID")
computer_name: str = Field(default="", description="计算机名")
ip: str = Field(default="", description="本地IP")
mac: str = Field(default="", description="MAC地址")
os_version: str = Field(default="", description="操作系统版本")
is_online: bool = Field(default=False, description="在线状态")
group_name: str = Field(default="", description="分组名称")
hardware: Optional[HardwareInfo] = Field(default=None, description="硬件概要")
high_risk_leaks: int = Field(default=0, description="高危漏洞数")
uncleaned_virus: int = Field(default=0, description="未处理病毒事件数")
security_score: int = Field(default=100, description="安全评分(0-100)")
@@ -0,0 +1,15 @@
# 联软LV7000 API集成模块
"""
提供联软LV7000终端安全管理系统的API客户端。
认证方式:三层认证(IP白名单 + 账号密码 + Token
- 第一层:IP白名单(在联软后台配置WhiteListServerIp
- 第二层:账号密码(ApiAccount + ApiPassword
- 第三层:一次性Token(先调getToken获取,30分钟有效)
核心P0接口:
- queryDevByParams:按条件查询终端(含strusername员工账号映射)
- getDevAllInfo:终端详细信息(硬件+软件+资产+网络)
- getUserInfoByAccount:按账号查用户信息
- getAllOrgInfo:全量组织架构同步
"""
+604
View File
@@ -0,0 +1,604 @@
# 联软LV7000 API客户端
"""
联软LV7000终端安全管理系统 API 客户端。
认证流程:
1. 第一层:IP白名单(在联软后台配置,调用时自动生效)
2. 第二层:账号密码(ApiAccount + ApiPassword
3. 第三层:Token(先调getToken获取,30分钟有效,自动缓存+刷新)
接口调用方式:
- GET请求:参数通过query string传递
- POST请求:参数通过form-data传递
- 统一携带 token + apiAccount + apiPassword + validatekey
使用示例:
client = LianruanClient(base_url, api_account, api_password, validate_key)
terminals = await client.query_dev_by_params(strusername="songxian")
detail = await client.get_dev_all_info(strdevname="IT-SONGXIAN")
"""
import time
import logging
from typing import Optional
import httpx
from app.integrations.lianruan.exceptions import (
LianruanApiError,
LianruanAuthError,
LianruanConnectionError,
)
from app.integrations.lianruan.models import (
TerminalBasicInfo,
TerminalAllInfo,
UserInfo,
OrgDeptInfo,
OnlineStatus,
TerminalSoftwareInfo,
)
logger = logging.getLogger(__name__)
class LianruanClient:
"""联软LV7000 API客户端。
Attributes:
base_url: 联软API地址,如 http://192.168.x.x:30098
api_account: API账号
api_password: API密码
validate_key: 验证密钥
_token: 缓存的Token
_token_expire: Token过期时间戳
"""
def __init__(
self,
base_url: str,
api_account: str,
api_password: str,
validate_key: str = "",
timeout: float = 30.0,
):
self.base_url = base_url.rstrip("/")
self.api_account = api_account
self.api_password = api_password
self.validate_key = validate_key
self.timeout = timeout
# Token缓存(30分钟有效,提前5分钟刷新)
self._token: str = ""
self._token_expire: float = 0.0
# httpx异步客户端(连接池复用)
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建httpx异步客户端(懒初始化+连接池复用)"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=self.timeout,
verify=False, # 内网自签证书
)
return self._client
async def close(self) -> None:
"""关闭httpx客户端,释放连接池"""
if self._client and not self._client.is_closed:
await self._client.aclose()
# ==========================================================================
# Token管理
# ==========================================================================
async def _ensure_token(self) -> str:
"""确保Token有效,过期则自动刷新。
联软Token默认30分钟有效,提前5分钟刷新。
Returns:
str: 有效的Token字符串
"""
now = time.time()
# Token还有5分钟以上有效期,直接复用
if self._token and now < self._token_expire - 300:
return self._token
# 重新获取Token
logger.info("联软Token过期或为空,正在刷新...")
try:
client = await self._get_client()
url = f"{self.base_url}/token"
params = {
"act": "getToken",
"apiAccount": self.api_account,
"apiPassword": self.api_password,
}
if self.validate_key:
params["validatekey"] = self.validate_key
resp = await client.get(url, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "SUCCESS":
raise LianruanAuthError(
f"获取Token失败: {data.get('msg', '未知错误')}",
detail=str(data),
)
self._token = data.get("data", data.get("rows", ""))
if not self._token:
# 有些版本返回格式不同
self._token = str(data.get("token", ""))
# 30分钟有效期
self._token_expire = now + 1800
logger.info("联软Token刷新成功,有效期至 %s",
time.strftime("%H:%M:%S", time.localtime(self._token_expire)))
return self._token
except httpx.ConnectError as e:
raise LianruanConnectionError(
f"无法连接联软服务器 {self.base_url}: {e}",
detail=str(e),
)
except httpx.TimeoutException as e:
raise LianruanConnectionError(
f"连接联软服务器超时: {e}",
detail=str(e),
)
# ==========================================================================
# 通用请求方法
# ==========================================================================
async def _request(
self,
path: str,
act: str,
params: Optional[dict] = None,
method: str = "GET",
) -> dict:
"""发送请求到联软API。
自动携带认证参数(token + apiAccount + apiPassword)。
Args:
path: API路径,如 /terminal 或 /querydeptuser
act: 操作类型,如 queryDevByParams
params: 额外业务参数
method: 请求方法(GET/POST
Returns:
dict: 联软API返回的JSON数据
Raises:
LianruanAuthError: 认证失败
LianruanApiError: 业务错误
LianruanConnectionError: 网络错误
"""
token = await self._ensure_token()
client = await self._get_client()
# 构建完整参数:认证参数 + 业务参数
full_params = {
"act": act,
"apiAccount": self.api_account,
"apiPassword": self.api_password,
"token": token,
}
if self.validate_key:
full_params["validatekey"] = self.validate_key
if params:
full_params.update(params)
url = f"{self.base_url}{path}"
try:
if method.upper() == "POST":
resp = await client.post(url, data=full_params)
else:
resp = await client.get(url, params=full_params)
resp.raise_for_status()
data = resp.json()
except httpx.ConnectError as e:
raise LianruanConnectionError(
f"无法连接联软服务器: {e}",
detail=str(e),
)
except httpx.TimeoutException as e:
raise LianruanConnectionError(
f"请求联软超时: {e}",
detail=str(e),
)
except httpx.HTTPStatusError as e:
raise LianruanApiError(
f"联软HTTP错误 {e.response.status_code}",
status=str(e.response.status_code),
detail=str(e),
)
# 检查联软业务状态码
status = data.get("status", "")
if status == "INVALID":
# Token可能过期,清除缓存重试一次
self._token = ""
self._token_expire = 0
raise LianruanAuthError(
f"联软认证失败(IP不在白名单或Token无效): {data.get('msg', '')}",
detail=str(data),
)
elif status == "ERROR":
raise LianruanApiError(
f"联软API错误: {data.get('msg', '')}",
status=status,
detail=str(data),
)
elif status == "Exceed":
raise LianruanApiError(
f"联软数据量超限: {data.get('msg', '')}",
status=status,
detail=str(data),
)
elif status != "SUCCESS":
raise LianruanApiError(
f"联软未知状态: {status} - {data.get('msg', '')}",
status=status,
detail=str(data),
)
return data
# ==========================================================================
# P0接口 — 终端设备查询
# ==========================================================================
async def query_dev_by_params(
self,
strusername: str = "",
strdevname: str = "",
strdevip: str = "",
strmac: str = "",
page: int = 1,
per_page: int = 20,
) -> dict:
"""查询终端设备(核心映射接口)。
⭐ strusername 参数可直接按员工账号查终端,这是联软最大的优势!
Args:
strusername: 员工账号(映射金钥匙)
strdevname: 计算机名
strdevip: IP地址
strmac: MAC地址
page: 页码(从1开始)
per_page: 每页条数
Returns:
dict: {"items": [TerminalBasicInfo], "total": int}
"""
params: dict = {}
if strusername:
params["strusername"] = strusername
if strdevname:
params["strdevname"] = strdevname
if strdevip:
params["strdevip"] = strdevip
if strmac:
params["strmac"] = strmac
# 联软分页参数
params["page"] = str(page)
params["rows"] = str(per_page)
data = await self._request("/terminal", "queryDevByParams", params)
rows = data.get("rows", [])
total = data.get("total", len(rows))
items = [TerminalBasicInfo(**row) for row in rows]
return {"items": items, "total": total}
async def get_dev_all_info(
self,
strdevname: str = "",
strdevip: str = "",
) -> TerminalAllInfo:
"""查询终端详细信息(极详细硬件+软件+资产+网络)。
比火绒_info2更丰富,包含逻辑磁盘使用率、显示器信息、内存条详情。
Args:
strdevname: 计算机名(二选一)
strdevip: IP地址(二选一)
Returns:
TerminalAllInfo: 终端详细信息
"""
params: dict = {}
if strdevname:
params["strdevname"] = strdevname
if strdevip:
params["strdevip"] = strdevip
data = await self._request(
"/devallinfoshowwithpaging", "getDevAllInfo", params
)
# 返回格式:data.equipment + data.equipmentdetail
equipment = data.get("equipment", data.get("rows", [{}]))
if isinstance(equipment, list) and equipment:
equipment = equipment[0]
equipment_detail = data.get("equipmentdetail", {})
if isinstance(equipment_detail, list) and equipment_detail:
equipment_detail = equipment_detail[0]
dev_detail = equipment_detail.get("devdetail", equipment_detail)
# 解析硬件详情
result = TerminalAllInfo(
strdevname=equipment.get("strdevname", ""),
strip1=equipment.get("strip1", ""),
strmac=equipment.get("strmac", ""),
strdeptname=equipment.get("strdeptname", ""),
strusername=equipment.get("strusername", ""),
struserdes=equipment.get("struserdes", ""),
stros=equipment.get("stros", ""),
strdomain=equipment.get("strdomain", ""),
istatus=dev_detail.get("istatus", equipment.get("istatus", "")),
strverofuaagent=dev_detail.get("strverofuaagent", ""),
devassetno=dev_detail.get("devassetno", ""),
devgroup=dev_detail.get("devgroup", ""),
)
# 解析硬件列表
self._parse_hardware_list(dev_detail, "CPUInformation", result.cpu)
self._parse_hardware_list(dev_detail, "MemoryInformation", result.memory)
self._parse_hardware_list(dev_detail, "HardDiskInformation", result.hard_disk)
self._parse_hardware_list(dev_detail, "GraphicsCardInformation", result.graphics_card)
self._parse_hardware_list(dev_detail, "MainboardInformation", result.mainboard)
# 解析逻辑磁盘(含使用率)
for ld in dev_detail.get("LogicalDiskInformation", []):
result.logical_disk.append(LogicalDiskInfo(
name=ld.get("strlogicaldiskname", ""),
file_system=ld.get("strfilesystem", ""),
total_size=ld.get("strtotalsize", ""),
free_space=ld.get("strfreespace", ""),
usage_percent=ld.get("strusagepercent", ""),
))
# 解析网卡
for nc in dev_detail.get("NetworkCardInformation", []):
result.network_card.append(NetworkCardInfo(
name=nc.get("strnetcardname", ""),
is_wireless=nc.get("iswireless", ""),
vendor=nc.get("strnetcardvendor", ""),
mac=nc.get("strnetcardmac", ""),
))
# 解析显示器
for d in dev_detail.get("DisplayInformation", []):
result.display.append(DisplayInfo(
vendor=d.get("strdisplayvendor", ""),
model=d.get("strdisplaymodel", ""),
serial=d.get("strdisplayserial", ""),
size=d.get("strdisplaysize", ""),
))
return result
def _parse_hardware_list(
self, dev_detail: dict, key: str, target_list: list
) -> None:
"""解析硬件信息列表(CPU/内存/硬盘等)"""
from app.integrations.lianruan.models import HardwareInfo
for item in dev_detail.get(key, []):
target_list.append(HardwareInfo(
name=item.get("strcpuname", item.get("strmemname", item.get("strdiskname", ""))),
model=item.get("strcpumodel", item.get("strmemmodel", item.get("strdiskmodel", ""))),
vendor=item.get("strcpuvendor", item.get("strmemvendor", item.get("strdiskvendor", ""))),
capacity=item.get("strcpufrequency", item.get("strmemcapacity", item.get("strdiskcapacity", ""))),
serial=item.get("strcpuserial", item.get("strmemserial", item.get("strdiskserial", ""))),
))
# ==========================================================================
# P0接口 — 组织架构/用户
# ==========================================================================
async def get_user_info_by_account(self, useraccount: str) -> Optional[UserInfo]:
"""按账号查询用户信息。
Args:
useraccount: 用户账号
Returns:
UserInfo或None
"""
data = await self._request(
"/querydeptuser",
"getUserInfoByAccount",
{"useraccount": useraccount},
)
rows = data.get("rows", data.get("row", []))
if rows:
row = rows[0] if isinstance(rows, list) else rows
return UserInfo(**row)
return None
async def get_all_org_info(self) -> list[OrgDeptInfo]:
"""获取全量组织架构(部门+用户)。
用于定时同步,构建组织架构映射。
Returns:
list[OrgDeptInfo]: 部门列表,每个部门含用户列表
"""
data = await self._request("/querydeptuser", "getAllOrgInfo")
rows = data.get("rows", [])
result = []
for dept_data in rows:
users = []
for u in dept_data.get("users", []):
users.append(UserInfo(**u))
result.append(OrgDeptInfo(
deptid=dept_data.get("deptid", ""),
deptname=dept_data.get("deptname", ""),
parentid=dept_data.get("parentid", ""),
users=users,
))
return result
# ==========================================================================
# P1接口 — 准入控制
# ==========================================================================
async def exist_online_user(
self, username: str, strdevip: str = ""
) -> OnlineStatus:
"""查询终端用户是否在线。
可精确判断某员工在某IP是否当前在线。
Args:
username: 用户名
strdevip: IP地址(可选)
Returns:
OnlineStatus: 在线状态
"""
params = {"username": username}
if strdevip:
params["strdevip"] = strdevip
data = await self._request(
"/access/onlineUser", "existOnlineUser", params
)
is_online = data.get("data", "0") == "1"
return OnlineStatus(
username=username,
ip=strdevip,
is_online=is_online,
)
# ==========================================================================
# P1接口 — 终端操作
# ==========================================================================
async def notice_agent_msg(
self, strdevip: str, message: str
) -> bool:
"""向终端推送弹窗消息。
Args:
strdevip: 终端IP
message: 消息内容
Returns:
bool: 是否成功
"""
data = await self._request(
"/terminal",
"noticeAgentMsg",
{"strdevip": strdevip, "msg": message},
)
return data.get("status") == "SUCCESS"
async def remote_wake_up(
self, strdevip: str, strmac: str
) -> bool:
"""远程唤醒终端。
Args:
strdevip: 终端IP
strmac: 终端MAC地址
Returns:
bool: 是否成功
"""
data = await self._request(
"/terminal",
"remoteWakeUp",
{"strdevip": strdevip, "strmac": strmac},
)
return data.get("status") == "SUCCESS"
async def query_software_by_dev(
self, strdevname: str = "", strdevip: str = ""
) -> Optional[TerminalSoftwareInfo]:
"""查询终端安装软件。
Args:
strdevname: 计算机名
strdevip: IP地址
Returns:
TerminalSoftwareInfo或None
"""
params: dict = {}
if strdevname:
params["strdevname"] = strdevname
if strdevip:
params["strdevip"] = strdevip
data = await self._request("/software", "querysoftwarebydev", params)
rows = data.get("rows", [])
if not rows:
return None
row = rows[0] if isinstance(rows, list) else rows
softwares = []
for s in row.get("softwares", []):
softwares.append(SoftwareInfo(
name=s.get("strsoftware", ""),
version=s.get("strversion", ""),
vendor=s.get("strvendor", ""),
install_date=s.get("installdate", ""),
))
return TerminalSoftwareInfo(
strdevname=row.get("strdevname", ""),
strdevip=row.get("strdevip", ""),
strmac=row.get("strmac", ""),
strusername=row.get("strusername", ""),
softwares=softwares,
)
# ==========================================================================
# 测试连接
# ==========================================================================
async def test_connection(self) -> dict:
"""测试联软API连接。
使用getToken接口验证:
1. 网络连通性
2. IP白名单
3. 账号密码正确性
4. Token获取成功
Returns:
dict: {"success": bool, "message": str}
"""
try:
token = await self._ensure_token()
if token:
return {
"success": True,
"message": "联软API连接成功,Token获取正常",
}
else:
return {
"success": False,
"message": "Token获取失败,返回为空",
}
except LianruanAuthError as e:
return {"success": False, "message": e.message}
except LianruanConnectionError as e:
return {"success": False, "message": e.message}
except Exception as e:
return {"success": False, "message": f"未知错误: {str(e)}"}
@@ -0,0 +1,98 @@
# 联软LV7000配置管理
"""
从system_configs表读取联软API配置,构建LianruanClient实例。
联软配置键(前缀 integration_lianruan_):
- integration_lianruan_base_url: 联软API地址(如 http://192.168.x.x:30098
- integration_lianruan_api_account: API账号
- integration_lianruan_api_password: API密码
- integration_lianruan_validate_key: 验证密钥(可选)
配置方式:管理后台 → 系统集成 → 联软LV7000 → 填入账号密码
"""
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.lianruan.client import LianruanClient
from app.integrations.lianruan.exceptions import LianruanConfigError
from app.models.system_config import SystemConfig
logger = logging.getLogger(__name__)
# 联软配置键前缀(与 admin_service INTEGRATION_DEFINITIONS 中的 key_prefix 一致)
_PREFIX = "integration_lianruan_"
async def _get_lianruan_config_value(db: AsyncSession, key_suffix: str) -> str:
"""读取单个联软配置值。
Args:
db: 数据库会话
key_suffix: 配置键后缀(如 base_url / api_account
Returns:
str: 配置值,不存在返回空字符串
"""
full_key = f"{_PREFIX}{key_suffix}"
from sqlalchemy import select
result = await db.execute(select(SystemConfig).where(SystemConfig.key == full_key))
config_row = result.scalar_one_or_none()
return config_row.value if config_row else ""
async def get_lianruan_config(db: AsyncSession) -> dict:
"""从system_configs表读取联软配置。
Args:
db: 数据库会话
Returns:
dict: 包含 base_url / api_account / api_password / validate_key
Raises:
LianruanConfigError: 配置缺失
"""
base_url = await _get_lianruan_config_value(db, "base_url")
api_account = await _get_lianruan_config_value(db, "api_account")
api_password = await _get_lianruan_config_value(db, "api_password")
validate_key = await _get_lianruan_config_value(db, "validate_key")
if not base_url:
raise LianruanConfigError("联软API未配置:缺少Base URL")
if not api_account:
raise LianruanConfigError("联软API未配置:缺少API账号")
if not api_password:
raise LianruanConfigError("联软API未配置:缺少API密码")
return {
"base_url": base_url,
"api_account": api_account,
"api_password": api_password,
"validate_key": validate_key,
}
async def get_lianruan_client(db: AsyncSession) -> LianruanClient:
"""构建联软API客户端实例。
从system_configs表读取配置,创建LianruanClient。
Args:
db: 数据库会话
Returns:
LianruanClient: 已配置的联软客户端
Raises:
LianruanConfigError: 配置缺失
"""
cfg = await get_lianruan_config(db)
return LianruanClient(
base_url=cfg["base_url"],
api_account=cfg["api_account"],
api_password=cfg["api_password"],
validate_key=cfg.get("validate_key", ""),
)
@@ -0,0 +1,61 @@
# 联软LV7000异常体系
"""
定义联软API集成的异常类层级。
层级:
LianruanError — 基类(所有联软异常)
├── LianruanConfigError — 配置缺失(未填写账号/密码/BaseURL)
├── LianruanAuthError — 认证失败(IP不在白名单/账号密码错误/Token过期)
├── LianruanConnectionError — 网络连接失败(超时/拒绝连接)
└── LianruanApiError — API业务错误(参数错误/数据超限/其他)
"""
class LianruanError(Exception):
"""联软异常基类"""
def __init__(self, message: str, detail: str = ""):
self.message = message
self.detail = detail
super().__init__(message)
class LianruanConfigError(LianruanError):
"""配置缺失异常。
场景:未配置联软 BaseURL / ApiAccount / ApiPassword
"""
pass
class LianruanAuthError(LianruanError):
"""认证失败异常。
场景:
- IP不在白名单(status=INVALID
- 账号密码错误
- Token过期(需重新获取)
"""
pass
class LianruanConnectionError(LianruanError):
"""网络连接失败异常。
场景:超时/拒绝连接/DNS解析失败
"""
pass
class LianruanApiError(LianruanError):
"""API业务错误异常。
场景:
- 参数错误(status=ERROR
- 数据量超限(status=Exceed
- 其他业务异常
"""
def __init__(self, message: str, status: str = "", detail: str = ""):
self.status = status # 联软返回的status字段(ERROR/Exceed等)
super().__init__(message, detail)
+193
View File
@@ -0,0 +1,193 @@
# 联软LV7000数据模型
"""
定义联软API返回数据的Pydantic模型。
核心模型:
- TerminalBasicInfo:终端基本信息(queryDevByParams返回)
- TerminalAllInfo:终端详细信息(getDevAllInfo返回,极详细)
- UserInfo:用户信息(getUserInfoByAccount返回)
- OrgInfo:组织架构信息(getAllOrgInfo返回)
- OnlineStatus:终端在线状态(existOnlineUser返回)
"""
from typing import Optional
from pydantic import BaseModel, Field
# ==========================================================================
# 终端基本信息(queryDevByParams返回)
# ==========================================================================
class TerminalBasicInfo(BaseModel):
"""终端基本信息 — 最核心的映射数据源。
⭐ strusername + struserdes 字段直接提供员工账号→终端映射!
这是联软相比火绒最大的优势。
"""
# 终端标识
strdevname: str = Field(default="", description="计算机名")
strdevip: str = Field(default="", description="IP地址")
strmac: str = Field(default="", description="MAC地址")
# ⭐ 员工映射字段(核心价值)
strusername: str = Field(default="", description="使用该终端的用户账号(映射金钥匙)")
struserdes: str = Field(default="", description="用户姓名/描述")
# 组织信息
strdeptname: str = Field(default="", description="所属部门名")
# 状态
istatus: str = Field(default="", description="终端状态(1=在线/0=离线)")
# 网络
strswitchname: str = Field(default="", description="接入交换机名")
strifname: str = Field(default="", description="交换机接口名")
# 联系方式
strmail: str = Field(default="", description="用户邮箱")
strphone: str = Field(default="", description="用户电话")
# 其他
strdomain: str = Field(default="", description="Windows域")
strdevtype: str = Field(default="", description="设备类型")
# ==========================================================================
# 终端详细信息(getDevAllInfo返回)
# ==========================================================================
class HardwareInfo(BaseModel):
"""硬件组件信息"""
name: str = Field(default="", description="名称")
model: str = Field(default="", description="型号")
vendor: str = Field(default="", description="厂商")
capacity: str = Field(default="", description="容量")
serial: str = Field(default="", description="序列号")
class LogicalDiskInfo(BaseModel):
"""逻辑磁盘信息(含使用率,判断磁盘满)"""
name: str = Field(default="", description="卷标")
file_system: str = Field(default="", description="文件系统")
total_size: str = Field(default="", description="总量")
free_space: str = Field(default="", description="可用空间")
usage_percent: str = Field(default="", description="使用率")
class NetworkCardInfo(BaseModel):
"""网卡信息"""
name: str = Field(default="", description="名称")
is_wireless: str = Field(default="", description="是否无线")
vendor: str = Field(default="", description="厂商")
mac: str = Field(default="", description="MAC地址")
class DisplayInfo(BaseModel):
"""显示器信息(多屏配置排查)"""
vendor: str = Field(default="", description="厂商")
model: str = Field(default="", description="型号")
serial: str = Field(default="", description="序列号")
size: str = Field(default="", description="尺寸")
class TerminalAllInfo(BaseModel):
"""终端详细信息 — 极其详细,比火绒_info2更丰富。
包含:设备基础+硬件+软件+资产+网络配置。
特别是逻辑磁盘使用率和显示器信息,是火绒没有的。
"""
# 设备基础
strdevname: str = Field(default="", description="计算机名")
strip1: str = Field(default="", description="IP地址")
strmac: str = Field(default="", description="MAC地址")
strnatip: str = Field(default="", description="NAT IP")
macverdor: str = Field(default="", description="MAC厂商")
strdevtype: str = Field(default="", description="设备类型")
# 组织+用户
strdeptname: str = Field(default="", description="所属部门")
strusername: str = Field(default="", description="用户账号⭐")
struserdes: str = Field(default="", description="用户姓名⭐")
# 时间
dtdevuptime: str = Field(default="", description="最近上线时间")
dtdevdowntime: str = Field(default="", description="最近下线时间")
dtdevfirstfoundtime: str = Field(default="", description="首次发现时间")
# 系统
stros: str = Field(default="", description="操作系统")
strdomain: str = Field(default="", description="Windows域")
strserialnumber: str = Field(default="", description="序列号")
strmainboardtype: str = Field(default="", description="主板型号")
# 客户端详情
strverofuaagent: str = Field(default="", description="安全助手版本")
istatus: str = Field(default="", description="在线状态")
devassetno: str = Field(default="", description="设备资产号")
devgroup: str = Field(default="", description="设备所属设备组")
# 硬件详情(列表)
mainboard: list[HardwareInfo] = Field(default_factory=list, description="主板信息")
cpu: list[HardwareInfo] = Field(default_factory=list, description="CPU信息")
memory: list[HardwareInfo] = Field(default_factory=list, description="内存信息")
hard_disk: list[HardwareInfo] = Field(default_factory=list, description="硬盘信息")
logical_disk: list[LogicalDiskInfo] = Field(default_factory=list, description="逻辑磁盘")
graphics_card: list[HardwareInfo] = Field(default_factory=list, description="显卡信息")
network_card: list[NetworkCardInfo] = Field(default_factory=list, description="网卡信息")
display: list[DisplayInfo] = Field(default_factory=list, description="显示器信息")
# ==========================================================================
# 用户信息(getUserInfoByAccount返回)
# ==========================================================================
class UserInfo(BaseModel):
"""用户信息"""
deptid: str = Field(default="", description="部门ID")
userid: str = Field(default="", description="用户ID")
useraccount: str = Field(default="", description="用户账号")
username: str = Field(default="", description="用户姓名")
# ==========================================================================
# 组织架构信息(getAllOrgInfo返回)
# ==========================================================================
class OrgDeptInfo(BaseModel):
"""部门信息"""
deptid: str = Field(default="", description="部门ID")
deptname: str = Field(default="", description="部门名称")
parentid: str = Field(default="", description="父部门ID")
users: list[UserInfo] = Field(default_factory=list, description="部门下用户列表")
# ==========================================================================
# 终端在线状态(existOnlineUser返回)
# ==========================================================================
class OnlineStatus(BaseModel):
"""终端在线状态"""
username: str = Field(default="", description="用户名")
ip: str = Field(default="", description="IP地址")
is_online: bool = Field(default=False, description="是否在线")
# ==========================================================================
# 软件信息(querysoftwarebydev返回)
# ==========================================================================
class SoftwareInfo(BaseModel):
"""软件安装信息"""
name: str = Field(default="", description="软件名称")
version: str = Field(default="", description="版本")
vendor: str = Field(default="", description="厂商")
install_date: str = Field(default="", description="安装日期")
class TerminalSoftwareInfo(BaseModel):
"""终端安装软件信息"""
strdevname: str = Field(default="", description="计算机名")
strdevip: str = Field(default="", description="IP地址")
strmac: str = Field(default="", description="MAC地址")
strusername: str = Field(default="", description="用户账号")
softwares: list[SoftwareInfo] = Field(default_factory=list, description="软件列表")
@@ -0,0 +1,35 @@
# =============================================================================
# RAGFlow 集成模块
# =============================================================================
from .client import RagflowClient
from .config import get_ragflow_client
from .exceptions import (
RagflowApiError,
RagflowAuthError,
RagflowConfigError,
RagflowConnectionError,
RagflowError,
)
from .models import (
DatasetInfo,
DocAggregate,
DocumentInfo,
RetrievalChunk,
RetrievalResult,
)
__all__ = [
"RagflowClient",
"get_ragflow_client",
"RagflowError",
"RagflowConfigError",
"RagflowAuthError",
"RagflowApiError",
"RagflowConnectionError",
"RetrievalChunk",
"DocAggregate",
"RetrievalResult",
"DatasetInfo",
"DocumentInfo",
]
+449
View File
@@ -0,0 +1,449 @@
# =============================================================================
# RAGFlow API 客户端
# =============================================================================
# 说明:封装 RAGFlow 知识检索引擎的 API 调用
# 核心功能:
# 1. 知识检索 — POST /api/v1/retrieval(核心接口)
# 2. 数据集管理 — 列出/创建/删除知识库
# 3. 文档管理 — 上传/列出/删除文档
# 4. 测试连接 — 验证 API Key 是否有效
# 认证方式:Authorization: Bearer <API_KEY>
# 参考文档:https://ragflow.io/docs/http_api_reference
# =============================================================================
import logging
from typing import Any, Dict, List, Optional
import httpx
from .exceptions import (
RagflowApiError,
RagflowAuthError,
RagflowConfigError,
RagflowConnectionError,
RagflowError,
)
from .models import (
DatasetInfo,
DocAggregate,
DocumentInfo,
RetrievalChunk,
RetrievalResult,
)
logger = logging.getLogger(__name__)
# 默认请求超时(秒)
DEFAULT_TIMEOUT = 30.0
# 默认分页大小
DEFAULT_PAGE_SIZE = 20
class RagflowClient:
"""RAGFlow API 客户端。
封装 RAGFlow 知识检索引擎的 API 调用,支持:
- 知识检索(核心功能)
- 数据集(知识库)管理
- 文档管理
- 连接测试
使用方式:
client = RagflowClient(
api_key="sk-xxx",
base_url="http://10.80.0.85:9380"
)
result = await client.retrieval("VPN怎么连?", dataset_ids=["xxx"])
"""
def __init__(
self,
api_key: str,
base_url: str = "http://10.80.0.85:9380",
timeout: float = DEFAULT_TIMEOUT,
):
"""初始化 RAGFlow 客户端。
Args:
api_key: RAGFlow API KeyBearer Token
base_url: RAGFlow API 基础地址(不含尾部斜杠)
timeout: 默认请求超时(秒)
Raises:
RagflowConfigError: API Key 为空
"""
if not api_key:
raise RagflowConfigError("RAGFlow API Key 不能为空")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def _headers(self) -> Dict[str, str]:
"""构建请求头。
Returns:
Dict: 包含 Authorization 和 Content-Type 的请求头
"""
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
json_data: Optional[Dict] = None,
params: Optional[Dict] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""统一请求封装。
Args:
method: HTTP 方法(GET/POST/PUT/DELETE
path: API 路径(如 /api/v1/retrieval
json_data: JSON 请求体
params: 查询参数
timeout: 覆盖默认超时
Returns:
Dict: API 响应的 JSON 数据
Raises:
RagflowAuthError: 认证失败(401
RagflowApiError: API 返回错误
RagflowConnectionError: 网络连接失败
"""
url = f"{self.base_url}{path}"
req_timeout = timeout or self.timeout
try:
async with httpx.AsyncClient() as client:
response = await client.request(
method=method,
url=url,
headers=self._headers(),
json=json_data,
params=params,
timeout=req_timeout,
)
# 处理 HTTP 错误
if response.status_code == 401:
raise RagflowAuthError("RAGFlow API Key 无效或已过期")
if response.status_code >= 400:
try:
err_body = response.json()
err_msg = err_body.get("message", response.text)
except Exception:
err_msg = response.text
raise RagflowApiError(
code=response.status_code,
message=f"RAGFlow API 错误 ({response.status_code}): {err_msg}",
)
# 解析响应
result = response.json()
# RAGFlow 统一响应格式:{code: 0, data: ..., message: ...}
if result.get("code") != 0:
raise RagflowApiError(
code=result.get("code", -1),
message=result.get("message", "未知错误"),
)
return result
except httpx.TimeoutException:
raise RagflowConnectionError(f"RAGFlow 请求超时 ({req_timeout}s): {path}")
except httpx.ConnectError:
raise RagflowConnectionError(f"RAGFlow 连接失败: {self.base_url}")
except (RagflowAuthError, RagflowApiError, RagflowConnectionError):
raise
except Exception as e:
raise RagflowError(f"RAGFlow 请求异常: {str(e)}")
# ==========================================================================
# 测试连接
# ==========================================================================
async def test_connection(self) -> Dict[str, Any]:
"""测试 RAGFlow API 连接。
通过列出数据集(limit=1)验证 API Key 是否有效。
Returns:
Dict: {success: bool, message: str}
"""
try:
result = await self.list_datasets(page=1, page_size=1)
return {
"success": True,
"message": f"连接成功,共 {result.get('total', 0)} 个知识库",
}
except RagflowAuthError:
return {"success": False, "message": "API Key 无效或已过期"}
except RagflowConnectionError as e:
return {"success": False, "message": f"连接失败: {e.message}"}
except RagflowError as e:
return {"success": False, "message": e.message}
# ==========================================================================
# 知识检索(核心接口)
# ==========================================================================
async def retrieval(
self,
question: str,
dataset_ids: Optional[List[str]] = None,
document_ids: Optional[List[str]] = None,
similarity_threshold: float = 0.2,
vector_similarity_weight: float = 0.3,
top_k: int = 1024,
keyword: bool = False,
highlight: bool = False,
) -> RetrievalResult:
"""知识检索 — 从知识库中搜索相关文档片段。
这是 RAGFlow 的核心接口,用于根据用户问题检索最相关的文本块。
Args:
question: 用户查询问题
dataset_ids: 要搜索的数据集ID列表(与 document_ids 二选一)
document_ids: 要搜索的文档ID列表
similarity_threshold: 最小相似度阈值(0-1),默认 0.2
vector_similarity_weight: 向量相似度权重(0-1),默认 0.3
top_k: 参与计算的块数量,默认 1024
keyword: 是否启用关键词匹配,默认 False
highlight: 是否高亮匹配术语,默认 False
Returns:
RetrievalResult: 检索结果(含文本块、文档聚合、总数)
Raises:
RagflowError: 检索失败
"""
body: Dict[str, Any] = {
"question": question,
"similarity_threshold": similarity_threshold,
"vector_similarity_weight": vector_similarity_weight,
"top_k": top_k,
"keyword": keyword,
"highlight": highlight,
}
if dataset_ids:
body["dataset_ids"] = dataset_ids
if document_ids:
body["document_ids"] = document_ids
result = await self._request("POST", "/api/v1/retrieval", json_data=body)
data = result.get("data", {})
# 解析文本块
chunks = [
RetrievalChunk.model_validate(chunk)
for chunk in data.get("chunks", [])
]
# 解析文档聚合
doc_aggs = [
DocAggregate.model_validate(agg)
for agg in data.get("doc_aggs", [])
]
return RetrievalResult(
chunks=chunks,
doc_aggs=doc_aggs,
total=data.get("total", 0),
)
# ==========================================================================
# 数据集(知识库)管理
# ==========================================================================
async def list_datasets(
self,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""列出所有数据集(知识库)。
Args:
page: 页码
page_size: 每页条数
Returns:
Dict: {items: List[DatasetInfo], total: int}
"""
result = await self._request(
"GET",
"/api/v1/datasets",
params={"page": page, "page_size": page_size},
)
data = result.get("data", {})
items = [
DatasetInfo.model_validate(ds)
for ds in data.get("datasets", [])
]
return {"items": items, "total": data.get("total", 0)}
async def create_dataset(
self,
name: str,
embedding_model: str = "BAAI/bge-m3@BAAI",
chunk_method: str = "naive",
permission: str = "me",
) -> DatasetInfo:
"""创建数据集(知识库)。
Args:
name: 数据集名称
embedding_model: 向量模型
chunk_method: 分块方法(naive/qa/book/laws 等)
permission: 权限(me/team
Returns:
DatasetInfo: 创建的数据集信息
"""
body = {
"name": name,
"embedding_model": embedding_model,
"chunk_method": chunk_method,
"permission": permission,
}
result = await self._request("POST", "/api/v1/datasets", json_data=body)
return DatasetInfo.model_validate(result.get("data", {}))
async def delete_dataset(self, dataset_ids: List[str]) -> bool:
"""删除数据集。
Args:
dataset_ids: 要删除的数据集ID列表
Returns:
bool: 是否成功
"""
await self._request(
"DELETE",
"/api/v1/datasets",
json_data={"ids": dataset_ids},
)
return True
# ==========================================================================
# 文档管理
# ==========================================================================
async def list_documents(
self,
dataset_id: str,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""列出数据集中的文档。
Args:
dataset_id: 数据集ID
page: 页码
page_size: 每页条数
Returns:
Dict: {items: List[DocumentInfo], total: int}
"""
result = await self._request(
"GET",
f"/api/v1/datasets/{dataset_id}/documents",
params={"page": page, "page_size": page_size},
)
data = result.get("data", {})
items = [
DocumentInfo.model_validate(doc)
for doc in data.get("documents", [])
]
return {"items": items, "total": data.get("total", 0)}
async def upload_document(
self,
dataset_id: str,
file_path: str,
file_name: Optional[str] = None,
) -> DocumentInfo:
"""上传文档到数据集。
Args:
dataset_id: 数据集ID
file_path: 本地文件路径
file_name: 文件名(可选,默认取 file_path 的文件名)
Returns:
DocumentInfo: 上传的文档信息
"""
import os
if not os.path.exists(file_path):
raise RagflowError(f"文件不存在: {file_path}")
fname = file_name or os.path.basename(file_path)
url = f"{self.base_url}/api/v1/datasets/{dataset_id}/documents"
try:
async with httpx.AsyncClient() as client:
with open(file_path, "rb") as f:
response = await client.post(
url=url,
headers={"Authorization": f"Bearer {self.api_key}"},
files={"file": (fname, f)},
timeout=60.0,
)
if response.status_code == 401:
raise RagflowAuthError()
result = response.json()
if result.get("code") != 0:
raise RagflowApiError(
code=result.get("code", -1),
message=result.get("message", "上传失败"),
)
docs = result.get("data", {}).get("documents", [])
if docs:
return DocumentInfo.model_validate(docs[0])
return DocumentInfo(name=fname)
except (RagflowAuthError, RagflowApiError):
raise
except Exception as e:
raise RagflowError(f"文档上传失败: {str(e)}")
async def delete_documents(
self,
dataset_id: str,
document_ids: List[str],
) -> bool:
"""删除文档。
Args:
dataset_id: 数据集ID
document_ids: 要删除的文档ID列表
Returns:
bool: 是否成功
"""
await self._request(
"DELETE",
f"/api/v1/datasets/{dataset_id}/documents",
json_data={"ids": document_ids},
)
return True
@@ -0,0 +1,61 @@
# =============================================================================
# RAGFlow 配置加载器
# =============================================================================
# 说明:从数据库 system_configs 表加载 RAGFlow 配置,创建客户端实例
# 配置项:integration_ragflow_api_url + integration_ragflow_api_key
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_config import SystemConfig
from .client import RagflowClient
from .exceptions import RagflowConfigError
logger = logging.getLogger(__name__)
# 默认 RAGFlow API 地址(生产环境)
DEFAULT_RAGFLOW_BASE_URL = "http://10.80.0.85:9380"
async def _get_config(db: AsyncSession, key: str) -> str:
"""从数据库读取单个配置值。"""
result = await db.execute(
select(SystemConfig.config_value).where(SystemConfig.config_key == key)
)
row = result.scalar()
return row if row else ""
async def get_ragflow_client(db: AsyncSession) -> RagflowClient:
"""从数据库配置创建 RAGFlow 客户端实例。
读取 system_configs 表中的:
- integration_ragflow_api_url: RAGFlow API 地址
- integration_ragflow_api_key: RAGFlow API Key
Args:
db: 数据库会话
Returns:
RagflowClient: 客户端实例
Raises:
RagflowConfigError: 配置缺失
"""
api_url = await _get_config(db, "integration_ragflow_api_url")
api_key = await _get_config(db, "integration_ragflow_api_key")
# 如果数据库没有配置,使用默认地址
if not api_url:
api_url = DEFAULT_RAGFLOW_BASE_URL
if not api_key:
raise RagflowConfigError(
"RAGFlow API Key 未配置,请在管理后台 → 集成管理 → RAGFlow 中设置"
)
return RagflowClient(api_key=api_key, base_url=api_url)
@@ -0,0 +1,35 @@
# =============================================================================
# RAGFlow API 异常定义
# =============================================================================
class RagflowError(Exception):
"""RAGFlow 基础异常。"""
def __init__(self, message: str = "RAGFlow 错误"):
self.message = message
super().__init__(self.message)
class RagflowConfigError(RagflowError):
"""配置错误(缺少 API Key 或 Base URL)。"""
def __init__(self, message: str = "RAGFlow 配置缺失"):
super().__init__(message)
class RagflowAuthError(RagflowError):
"""认证失败(API Key 无效)。"""
def __init__(self, message: str = "RAGFlow 认证失败"):
super().__init__(message)
class RagflowApiError(RagflowError):
"""API 调用失败(非 200 响应)。"""
def __init__(self, code: int = 0, message: str = "RAGFlow API 错误"):
self.code = code
super().__init__(message)
class RagflowConnectionError(RagflowError):
"""网络连接失败。"""
def __init__(self, message: str = "RAGFlow 连接失败"):
super().__init__(message)
+110
View File
@@ -0,0 +1,110 @@
# =============================================================================
# RAGFlow API 数据模型
# =============================================================================
# 说明:定义 RAGFlow API 请求/响应的 Pydantic 数据模型
# 参考:https://ragflow.io/docs/http_api_reference
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class RetrievalChunk(BaseModel):
"""检索返回的单个文本块。
Attributes:
id: 块唯一ID
content: 块内容文本
document_id: 所属文档ID
document_keyword: 所属文档名称
similarity: 综合相似度分数
term_similarity: 关键词相似度
vector_similarity: 向量相似度
highlight: 高亮标记的内容(可选)
"""
id: str = Field(default="", description="块唯一ID")
content: str = Field(default="", description="块内容文本")
document_id: str = Field(default="", description="所属文档ID")
document_keyword: str = Field(default="", description="所属文档名称")
similarity: float = Field(default=0.0, description="综合相似度分数")
term_similarity: float = Field(default=0.0, description="关键词相似度")
vector_similarity: float = Field(default=0.0, description="向量相似度")
highlight: Optional[str] = Field(default=None, description="高亮标记的内容")
model_config = {"from_attributes": True}
class DocAggregate(BaseModel):
"""文档聚合统计。
Attributes:
doc_id: 文档ID
doc_name: 文档名称
count: 命中的块数量
"""
doc_id: str = Field(default="", description="文档ID")
doc_name: str = Field(default="", description="文档名称")
count: int = Field(default=0, description="命中块数量")
model_config = {"from_attributes": True}
class RetrievalResult(BaseModel):
"""检索结果。
Attributes:
chunks: 命中的文本块列表
doc_aggs: 按文档聚合统计
total: 命中总数
"""
chunks: List[RetrievalChunk] = Field(default_factory=list, description="命中文本块列表")
doc_aggs: List[DocAggregate] = Field(default_factory=list, description="文档聚合统计")
total: int = Field(default=0, description="命中总数")
model_config = {"from_attributes": True}
class DatasetInfo(BaseModel):
"""数据集(知识库)信息。
Attributes:
id: 数据集ID
name: 数据集名称
chunk_method: 分块方法
permission: 权限
document_count: 文档数量
embedding_model: 向量模型
create_time: 创建时间
update_time: 更新时间
"""
id: str = Field(default="", description="数据集ID")
name: str = Field(default="", description="数据集名称")
chunk_method: str = Field(default="naive", description="分块方法")
permission: str = Field(default="me", description="权限")
document_count: int = Field(default=0, description="文档数量")
embedding_model: str = Field(default="", description="向量模型")
create_time: Optional[str] = Field(default=None, description="创建时间")
update_time: Optional[str] = Field(default=None, description="更新时间")
model_config = {"from_attributes": True}
class DocumentInfo(BaseModel):
"""文档信息。
Attributes:
id: 文档ID
name: 文档名称
chunk_method: 分块方法
chunk_count: 块数量
create_time: 创建时间
update_time: 更新时间
"""
id: str = Field(default="", description="文档ID")
name: str = Field(default="", description="文档名称")
chunk_method: str = Field(default="naive", description="分块方法")
chunk_count: int = Field(default=0, description="块数量")
create_time: Optional[str] = Field(default=None, description="创建时间")
update_time: Optional[str] = Field(default=None, description="更新时间")
model_config = {"from_attributes": True}
+533
View File
@@ -0,0 +1,533 @@
# =============================================================================
# 企微IT智能服务台 — FastAPI 应用入口
# =============================================================================
# 说明:FastAPI 应用的主入口文件,负责:
# 1. 创建 FastAPI 应用实例
# 2. 配置 CORS 跨域支持
# 3. 挂载 API 路由
# 4. 注册全局异常处理器
# 5. 添加启动事件(初始化默认数据)
# 6. 提供健康检查端点
# =============================================================================
import json
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
# 导入配置(读取环境变量)
from app.config import settings
# 导入路由汇总
from app.api.router import api_router
# 导入共享服务生命周期管理
from app.dependencies import init_shared_services, cleanup_shared_services
# 导入异常处理器和异常类
from app.utils.response import AppException, app_exception_handler
# 配置日志格式
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# 应用生命周期管理(启动和关闭事件)
# --------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理。
在应用启动时执行初始化操作(如插入默认数据),
在应用关闭时执行清理操作。
"""
# ===== 启动事件 =====
logger.info("🚀 企微IT智能服务台启动中...")
# 校验关键配置项(防止生产环境忘记配置导致静默失败)
_validate_config()
# 初始化共享服务实例(Redis/AIService/WecomService/AIHandler
# 这些实例在应用运行期间复用,避免每次请求重新创建导致资源泄漏
await init_shared_services()
# 自动建表(开发阶段,生产环境应用 Alembic 迁移)
await _auto_create_tables()
# 初始化默认数据
await _init_default_data()
logger.info("✅ 企微IT智能服务台启动完成")
yield # 应用运行中
# ===== 关闭事件 =====
logger.info("👋 企微IT智能服务台关闭中...")
# 清理共享服务实例(关闭 Redis 连接、httpx 连接池等)
await cleanup_shared_services()
logger.info("✅ 企微IT智能服务台已关闭")
# --------------------------------------------------------------------------
# 配置校验(启动时检查关键配置项是否为占位符)
# --------------------------------------------------------------------------
# 占位符列表:这些默认值在 config.py 中设置,生产环境必须替换
_PLACEHOLDER_VALUES = {
"wecom_corp_id": "ww1234567890abcdef",
"wecom_secret": "your-agent-secret",
"wecom_token": "your-callback-token",
"wecom_encoding_aes_key": "your-aes-key-43-characters-long-encoding-key",
}
def _validate_config():
"""校验关键配置项是否为占位符。
生产环境部署时,如果忘记修改 config.py 中的占位符值,
会导致 AES 解密静默失败、企微 API 调用 400 等问题。
此函数在启动时检查这些关键配置,输出醒目警告。
"""
warnings = []
for key, placeholder in _PLACEHOLDER_VALUES.items():
actual_value = getattr(settings, key, "")
if actual_value == placeholder:
warnings.append(f" ⚠️ {key} = '{placeholder}' (未配置!)")
if warnings:
logger.warning(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
"⚠️ 检测到以下关键配置仍为占位符,请修改 .env 或环境变量:\n"
+ "\n".join(warnings)
+ "\n"
" 企微回调消息将无法正常解密!\n"
" 参考 .env.example 或项目部署手册进行配置。\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
)
else:
logger.info("✅ 关键配置校验通过")
# --------------------------------------------------------------------------
# 自动建表(开发阶段使用)
# --------------------------------------------------------------------------
async def _auto_create_tables():
"""自动创建所有数据库表。
开发阶段使用,根据模型定义自动创建表。
生产环境应使用 Alembic 迁移来管理表结构变更。
工作原理:
1. 获取 engine(懒加载)
2. 通过 Base.metadata 收集所有模型定义
3. 执行 CREATE TABLE IF NOT EXISTS
"""
from app.database import _get_engine, Base
# 导入所有模型,确保 Base.metadata 知道所有表的定义
# 如果不导入,Base.metadata 里只有基类,不会建任何表
import app.models # noqa: F401
engine = _get_engine()
async with engine.begin() as conn:
# checkfirst=True: 只创建不存在的表,不会覆盖已有表和数据
await conn.run_sync(Base.metadata.create_all, checkfirst=True)
logger.info("数据库表检查/创建完成")
# --------------------------------------------------------------------------
# 初始化默认数据
# --------------------------------------------------------------------------
async def _init_default_data():
"""初始化默认数据。
当数据库表为空时,插入预置配置数据,包括:
1. system_configs — 系统配置(关键词、阈值、话术等)
2. funny_phrases — 趣味话术
3. quick_reply_templates — 快速回复模板
4. approval_links — 审批流程链接
5. software_downloads — 软件下载入口
只在表为空时插入,避免重复插入。
"""
from app.database import _get_session_factory
from app.models.system_config import SystemConfig
from app.models.funny_phrase import FunnyPhrase
from app.models.quick_reply_template import QuickReplyTemplate
from app.models.approval_link import ApprovalLink
from app.models.software_download import SoftwareDownload
async_session_factory = _get_session_factory()
async with async_session_factory() as db:
try:
# 1. 初始化系统配置
await _init_system_configs(db, SystemConfig)
# 2. 初始化趣味话术
await _init_funny_phrases(db, FunnyPhrase)
# 3. 初始化快速回复模板
await _init_quick_reply_templates(db, QuickReplyTemplate)
# 4. 初始化审批流程链接
await _init_approval_links(db, ApprovalLink)
# 5. 初始化软件下载入口
await _init_software_downloads(db, SoftwareDownload)
await db.commit()
logger.info("默认数据初始化完成")
except Exception as e:
await db.rollback()
logger.error(f"默认数据初始化失败: {e}")
async def _init_system_configs(db, SystemConfig):
"""初始化系统配置项。"""
from sqlalchemy import select, func
count_stmt = select(func.count(SystemConfig.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"system_configs 已有 {count} 条数据,跳过初始化")
return
configs = [
SystemConfig(config_key="hand_raise_keywords", config_value=json.dumps(["转人工", "人工", "人工服务", "真人", "客服", "帮我转人工", "找人工"], ensure_ascii=False), description="举手触发关键词"),
SystemConfig(config_key="emotion_keywords_angry", config_value=json.dumps(["崩溃", "愤怒", "投诉", "差劲", "垃圾", "太差了", "受不了"], ensure_ascii=False), description="愤怒情绪关键词"),
SystemConfig(config_key="emotion_keywords_urgent", config_value=json.dumps(["", "紧急", "马上", "立刻", "赶紧", "十万火急", "快点"], ensure_ascii=False), description="紧急情绪关键词"),
SystemConfig(config_key="emotion_keywords_worried", config_value=json.dumps(["担心", "害怕", "出错", "丢失", "完蛋", "糟糕"], ensure_ascii=False), description="担忧情绪关键词"),
SystemConfig(config_key="intervene_round_threshold", config_value="3", description="需介入追问轮次阈值"),
SystemConfig(config_key="urgency_base_keyword_score", config_value="1", description="关键词匹配基础加分"),
SystemConfig(config_key="urgency_emotion_bonus", config_value="1", description="情绪标记加成分"),
SystemConfig(config_key="urgency_vip_bonus", config_value="1", description="VIP加成分"),
SystemConfig(config_key="urgency_repeat_bonus", config_value="1", description="重复追问加成分"),
SystemConfig(config_key="polling_interval_seconds", config_value="3", description="坐席轮询间隔(秒)"),
SystemConfig(config_key="access_token_buffer_seconds", config_value="300", description="access_token提前刷新时间(秒)"),
SystemConfig(config_key="emergency_mode", config_value="false", description="应急模式开关(true=启用员工服务通道,智能服务台降级)"),
]
db.add_all(configs)
await db.flush()
logger.info(f"初始化 system_configs: {len(configs)}")
async def _init_funny_phrases(db, FunnyPhrase):
"""初始化趣味话术。"""
from sqlalchemy import select, func
count_stmt = select(func.count(FunnyPhrase.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"funny_phrases 已有 {count} 条数据,跳过初始化")
return
phrases = [
FunnyPhrase(scene="shake", content="大哥,俺这就去摇人,稍等...", tone="亲切", sort_order=1),
FunnyPhrase(scene="keyword", content="收到!这就帮您摇位大神来", tone="稍正式", sort_order=1),
FunnyPhrase(scene="waiting", content="人还在路上,别急别急~", tone="安抚", sort_order=1),
FunnyPhrase(scene="connected", content="人摇来了!IT坐席为您服务", tone="明确交接", sort_order=1),
FunnyPhrase(scene="timeout", content="坐席都在忙,不过AI还在呢,要不先聊聊?我再继续摇", tone="降级安抚", sort_order=1),
FunnyPhrase(scene="vip", content="这就帮您安排专家,请稍候", tone="正式", sort_order=1),
]
db.add_all(phrases)
await db.flush()
logger.info(f"初始化 funny_phrases: {len(phrases)}")
async def _init_quick_reply_templates(db, QuickReplyTemplate):
"""初始化快速回复模板。"""
from sqlalchemy import select, func
count_stmt = select(func.count(QuickReplyTemplate.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"quick_reply_templates 已有 {count} 条数据,跳过初始化")
return
templates = [
QuickReplyTemplate(category="账号", title="密码重置", content="您好{employee_name},您的密码重置链接已发送至您的企业邮箱,请在30分钟内完成操作。", variables=["employee_name"], sort_order=1),
QuickReplyTemplate(category="账号", title="账号解锁", content="您好,您的账号已解锁,请5分钟后重新尝试登录。如仍有问题请联系IT服务台。", variables=[], sort_order=2),
QuickReplyTemplate(category="网络", title="VPN连接指引", content="请按以下步骤操作:1.打开VPN客户端 2.选择\u201c公司内网\u201d 3.输入域账号密码 4.点击连接。详细图文教程请查看右侧\u201c操作步骤\u201d", variables=[], sort_order=3),
QuickReplyTemplate(category="网络", title="WiFi连接", content="公司WiFi名称:Office-5G,密码请咨询前台或查看工位标签。", variables=[], sort_order=4),
QuickReplyTemplate(category="软件", title="软件安装申请", content="您好,软件安装需要提交审批申请。请在右侧\u201c审批流程\u201d中点击\u201c软件安装申请\u201d链接提交。", variables=[], sort_order=5),
QuickReplyTemplate(category="硬件", title="设备报修", content="您好,设备报修请提交工单。请在右侧\u201c审批流程\u201d中点击\u201c设备报修\u201d链接提交,IT会在24小时内联系您。", variables=[], sort_order=6),
QuickReplyTemplate(category="通用", title="会话结束", content="您好,请问还有其他问题吗?如无其他问题,我将结束本次服务。祝您工作顺利!", variables=[], sort_order=7),
QuickReplyTemplate(category="通用", title="稍等回复", content="收到,我正在为您查询,请稍等片刻。", variables=[], sort_order=8),
]
db.add_all(templates)
await db.flush()
logger.info(f"初始化 quick_reply_templates: {len(templates)}")
async def _init_approval_links(db, ApprovalLink):
"""初始化审批流程链接。"""
from sqlalchemy import select, func
count_stmt = select(func.count(ApprovalLink.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"approval_links 已有 {count} 条数据,跳过初始化")
return
links = [
ApprovalLink(category="IT", title="软件安装申请", url="https://审批系统地址/software-install", sort_order=1),
ApprovalLink(category="IT", title="设备报修工单", url="https://审批系统地址/device-repair", sort_order=2),
ApprovalLink(category="IT", title="VPN开通申请", url="https://审批系统地址/vpn-apply", sort_order=3),
ApprovalLink(category="IT", title="权限申请", url="https://审批系统地址/permission-apply", sort_order=4),
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=5),
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=6),
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=7),
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=8),
]
db.add_all(links)
await db.flush()
logger.info(f"初始化 approval_links: {len(links)}")
async def _init_software_downloads(db, SoftwareDownload):
"""初始化软件下载入口。"""
from sqlalchemy import select, func
count_stmt = select(func.count(SoftwareDownload.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"software_downloads 已有 {count} 条数据,跳过初始化")
return
downloads = [
SoftwareDownload(category="办公", name="企业微信", version="最新版", platform="全平台", download_url="https://work.weixin.qq.com/#download", sort_order=1),
SoftwareDownload(category="办公", name="WPS Office", version="12.1", platform="Windows/Mac", download_url="https://www.wps.cn/download", sort_order=2),
SoftwareDownload(category="办公", name="Microsoft Teams", version="最新版", platform="全平台", download_url="https://www.microsoft.com/teams/download", sort_order=3),
SoftwareDownload(category="开发", name="VS Code", version="1.90", platform="Windows/Mac/Linux", download_url="https://code.visualstudio.com/download", sort_order=4),
SoftwareDownload(category="开发", name="Git", version="2.45", platform="Windows/Mac", download_url="https://git-scm.com/download", sort_order=5),
SoftwareDownload(category="安全", name="公司VPN客户端", version="3.2", platform="Windows/Mac", download_url="https://内部下载地址/vpn-client", sort_order=6),
SoftwareDownload(category="工具", name="7-Zip", version="24.06", platform="Windows", download_url="https://www.7-zip.org/download", sort_order=7),
SoftwareDownload(category="工具", name="PDF阅读器", version="最新版", platform="Windows/Mac", download_url="https://get.adobe.com/reader/", sort_order=8),
]
db.add_all(downloads)
await db.flush()
logger.info(f"初始化 software_downloads: {len(downloads)}")
# --------------------------------------------------------------------------
# 创建 FastAPI 应用
# --------------------------------------------------------------------------
def create_app() -> FastAPI:
"""创建并配置 FastAPI 应用实例。
使用工厂函数模式,方便测试时创建不同的应用实例。
Returns:
FastAPI: 配置好的应用实例
"""
# 创建 FastAPI 实例
# lifespan: 应用生命周期管理(启动/关闭事件)
app = FastAPI(
title="企微IT智能服务台",
description="基于企微自建应用消息API的IT服务坐席系统",
version="1.0.0",
lifespan=lifespan,
)
# ----------------------------------------------------------------------
# 配置 CORS(跨域资源共享)
# ----------------------------------------------------------------------
# 为什么需要 CORS:前端和后端运行在不同端口,浏览器会阻止跨域请求
# allow_origins: 允许的前端地址列表
# allow_credentials: 允许携带 Cookie
# allow_methods: 允许的 HTTP 方法(仅允许必要的方法)
# allow_headers: 允许的请求头(仅允许必要的头)
# ----------------------------------------------------------------------
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Employee-Id"],
)
# ----------------------------------------------------------------------
# 速率限制(防止暴力破解和 DDoS)
# ----------------------------------------------------------------------
# slowapi 为每个 IP 维护请求计数器(默认内存后端)
# 登录接口严格限制(防暴力破解),普通接口宽松限制(防滥用)
# ----------------------------------------------------------------------
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from starlette.responses import JSONResponse as RateLimitJSONResponse
from app.utils.response import error_response as _rl_error_response
# 速率限制器:按客户端 IP 维度限制
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
# python-dotenv 已在应用启动时处理 .env 文件
limiter = Limiter(key_func=get_remote_address)
# 注册速率限制超限处理器
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request, exc: RateLimitExceeded):
"""速率限制超限响应:返回 429 状态码和友好提示。"""
return RateLimitJSONResponse(
status_code=429,
content=_rl_error_response(429, f"请求过于频繁,请 {exc.detail} 后重试"),
)
# ----------------------------------------------------------------------
# 注册全局异常处理器
# ----------------------------------------------------------------------
# 当业务逻辑抛出 AppException 时,自动转换为统一响应格式
# ----------------------------------------------------------------------
app.add_exception_handler(AppException, app_exception_handler)
# ----------------------------------------------------------------------
# 注册兜底异常处理器(捕获所有未预期的异常,避免裸 500)
# ----------------------------------------------------------------------
# 数据库连接失败、Redis 异常、第三方库错误等非 AppException 异常
# 都会被捕获并返回统一格式的错误响应,同时记录详细日志
# ----------------------------------------------------------------------
import traceback
from fastapi.responses import JSONResponse
from app.utils.response import error_response
@app.exception_handler(Exception)
async def catch_all_exception_handler(request, exc):
"""兜底异常处理器:捕获所有未预期异常。
安全处理:
- 详细异常信息记录到日志(供排查)
- 响应只返回通用错误信息(避免泄露内部细节)
"""
# 记录完整错误堆栈(用于排查问题)
logger.error(f"未预期异常: {exc}\n{traceback.format_exc()}")
# 返回统一格式的错误响应(HTTP 200 + 业务错误码)
# 安全:响应不包含具体异常信息,仅返回通用消息
return JSONResponse(
status_code=200,
content=error_response(1005, "服务器内部错误,请稍后重试或联系管理员")
)
# ----------------------------------------------------------------------
# 请求日志 + 兜底异常中间件
# ----------------------------------------------------------------------
# 使用中间件而非 exception_handler 来捕获所有异常
# 原因:FastAPI 的 @app.exception_handler(Exception) 在某些情况下
# 无法捕获异常(如依赖注入 yield 阶段的异常),而中间件更可靠
# ----------------------------------------------------------------------
import traceback as tb_module
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse, JSONResponse as StarJSONResponse
from app.utils.response import error_response as _error_response
@app.middleware("http")
async def catch_errors_and_log(request: Request, call_next):
"""请求日志 + 兜底异常中间件。
1. 记录每个请求的方法、路径、状态码
2. 捕获所有未处理异常,返回统一格式的 JSON 错误响应
"""
# 使用 print 而非 logger,确保输出立即可见(调试阶段)
print(f">>> [MW] 收到请求: {request.method} {request.url.path}", flush=True)
try:
response: StarletteResponse = await call_next(request)
print(f"<<< [MW] 响应完成: {request.method} {request.url.path}{response.status_code}", flush=True)
return response
except Exception as e:
# 捕获所有未处理异常(包括依赖注入阶段的异常)
# 安全:详细日志仅记录,响应不泄露异常信息
error_tb = tb_module.format_exc()
print(f"!!! [MW] 未捕获异常: {request.method} {request.url.path}\n{error_tb}", flush=True)
logger.error(f"!!! 未捕获异常: {request.method} {request.url.path}\n{error_tb}")
# 返回统一格式的 JSON 错误响应(HTTP 200 + 业务错误码 1005
# 安全:响应不包含具体异常信息
return StarJSONResponse(
status_code=200,
content=_error_response(1005, "服务器内部错误,请稍后重试或联系管理员"),
)
# ----------------------------------------------------------------------
# 挂载 API 路由
# ----------------------------------------------------------------------
# 注意:nginx 已经通过 location /api/ 处理了前缀路由,
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
app.include_router(api_router)
# ----------------------------------------------------------------------
# 挂载 WebSocket 路由
# ----------------------------------------------------------------------
# WebSocket 端点不挂 /api 前缀,直接注册在根路径
# 原因:WebSocket 不是 REST API,前端通过 /ws/{agent_id} 连接
# Vite 开发服务器单独配置了 /ws 的 WebSocket 代理
# ----------------------------------------------------------------------
from app.api.ws import router as ws_router
app.include_router(ws_router)
# ----------------------------------------------------------------------
# 诊断端点(调试用,生产环境删除)
# ----------------------------------------------------------------------
@app.get("/test-ping", tags=["诊断"])
async def test_ping():
"""简单测试 — 不依赖数据库和 Redis"""
return {"code": 0, "message": "success", "data": {"message": "pong"}}
@app.get("/test-error", tags=["诊断"])
async def test_error():
"""测试异常处理 — 故意抛出异常"""
raise Exception("这是故意抛出的测试异常")
# ----------------------------------------------------------------------
# 健康检查端点
# ----------------------------------------------------------------------
# 用于 Docker 健康检查和负载均衡探针
# 返回简单的 JSON 表示服务正在运行
@app.get("/health", tags=["系统"])
async def health_check():
"""健康检查端点。
返回服务运行状态,用于:
- Docker 健康检查
- 负载均衡探针
- 监控系统检测服务是否存活
"""
return {"status": "ok", "service": "wecom-it-smart-desk"}
# ----------------------------------------------------------------------
# 打印所有已注册的路由(调试用)
# ----------------------------------------------------------------------
routes_info = []
for route in app.routes:
if hasattr(route, 'methods') and hasattr(route, 'path'):
routes_info.append(f" {', '.join(route.methods)} {route.path}")
if routes_info:
logger.info(f"已注册路由 ({len(routes_info)} 个):\n" + "\n".join(routes_info))
else:
logger.warning("⚠️ 没有注册任何路由!")
return app
# 创建应用实例(uvicorn 通过 app.main:app 引用此对象)
app = create_app()
+44
View File
@@ -0,0 +1,44 @@
# =============================================================================
# 企微IT智能服务台 — 模型包初始化
# =============================================================================
# 说明:导出所有模型类,方便 Alembic 和其他模块统一导入
# 注意:即使某些模型在当前文件未直接使用,也必须导入
# 否则 Alembic 无法检测到这些模型,不会生成对应的迁移脚本
# =============================================================================
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.agent import Agent
from app.models.quick_reply_template import QuickReplyTemplate
from app.models.system_config import SystemConfig
from app.models.funny_phrase import FunnyPhrase
from app.models.approval_link import ApprovalLink
from app.models.software_download import SoftwareDownload
from app.models.agent_note import AgentNote
from app.models.employee import Employee
from app.models.todo_item import TodoItem
from app.models.troubleshooting_template import TroubleshootingTemplate
from app.models.config_change_log import ConfigChangeLog
from app.models.role import Role
from app.models.user_role import UserRole
from app.models.role_mapping_rule import RoleMappingRule
# 所有模型类的列表,方便遍历
__all__ = [
"Conversation",
"Message",
"Agent",
"QuickReplyTemplate",
"SystemConfig",
"FunnyPhrase",
"ApprovalLink",
"SoftwareDownload",
"AgentNote",
"Employee",
"TodoItem",
"TroubleshootingTemplate",
"ConfigChangeLog",
"Role",
"UserRole",
"RoleMappingRule",
]
+146
View File
@@ -0,0 +1,146 @@
# =============================================================================
# 企微IT智能服务台 — 坐席模型
# =============================================================================
# 说明:对应数据库 agents 表,存储坐席(IT服务人员)信息
# 坐席状态:online(在线)/offline(离线)/busy(忙碌)
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Agent(Base):
"""坐席模型 — 对应 agents 表。
记录坐席的基本信息和状态,用于消息分配和负载管理。
Attributes:
id: 坐席唯一标识(UUID,数据库自动生成)
user_id: 企微用户ID(唯一,关联企微通讯录)
name: 坐席姓名
status: 坐席状态(online/offline/busy
current_load: 当前服务会话数
max_load: 最大同时服务数(默认5)
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "agents"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 企微用户ID(唯一,用于关联企微通讯录和登录认证)
user_id: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
comment="企微用户ID(唯一)",
)
# 坐席姓名
name: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="坐席姓名",
)
# 坐席状态(CHECK 约束:只能取三种值)
# online: 在线,可以接收新的会话分配
# offline: 离线,不接收任何会话
# busy: 忙碌,不接收新会话但继续处理已有的
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="offline",
comment="坐席状态: online/offline/busy",
)
# 当前服务会话数(分配新会话时 +1,结单时 -1)
current_load: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="当前服务会话数",
)
# 最大同时服务数(坐席同时处理的会话数上限)
# 默认5个,可根据坐席能力调整
max_load: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=5,
comment="最大同时服务数",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# 角色(admin=组长, agent=坐席)
# 管理后台需要 admin 角色才能访问,坐席端无限制
role: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="agent",
comment="角色:admin=组长, agent=坐席",
)
# 技能标签列表(JSON 数组,存储坐席的技能分类)
# 可选值:电脑/软件/外设/网络/安全/资产/其他
skill_tags: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)",
)
# OTP密钥(用于TOTP动态码验证,为空表示未绑定)
otp_secret: Mapped[str] = mapped_column(
String(32),
nullable=True,
default=None,
comment="OTP密钥(Base32编码)",
)
# OTP是否启用(admin角色强制启用)
otp_enabled: Mapped[bool] = mapped_column(
Integer,
nullable=False,
default=0,
comment="OTP是否启用(0=否, 1=是)",
)
def __repr__(self) -> str:
"""坐席对象的字符串表示,方便调试。"""
return (
f"<Agent(id={self.id}, name={self.name}, "
f"status={self.status}, load={self.current_load}/{self.max_load})>"
)
+100
View File
@@ -0,0 +1,100 @@
# =============================================================================
# 企微IT智能服务台 — 坐席备注模型
# =============================================================================
# 说明:对应数据库 agent_notes 表,存储坐席对会话的备注
# 用途:坐席可以记录处理过程中的关键信息,方便后续跟进
# 一个会话可以有多条备注(不同坐席或同一坐席多次记录)
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AgentNote(Base):
"""坐席备注模型 — 对应 agent_notes 表。
记录坐席在处理会话时添加的备注信息。
一个会话可以有多条备注。
Attributes:
id: 备注唯一标识(UUID,数据库自动生成)
conversation_id: 所属会话ID(外键,关联 conversations 表)
agent_id: 坐席ID
content: 备注内容
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "agent_notes"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 所属会话ID(外键,关联 conversations 表)
# ON DELETE CASCADE:删除会话时自动删除该会话的所有备注
conversation_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
comment="所属会话ID",
)
# 坐席ID(记录是哪个坐席写的备注)
agent_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="坐席ID",
)
# 备注内容(坐席自由输入的文本)
content: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="备注内容",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按会话ID查询(如获取某会话的所有备注)
Index("idx_an_conversation", "conversation_id"),
)
def __repr__(self) -> str:
"""备注对象的字符串表示,方便调试。"""
return (
f"<AgentNote(id={self.id}, conv={self.conversation_id}, "
f"agent={self.agent_id})>"
)
+104
View File
@@ -0,0 +1,104 @@
# =============================================================================
# 企微IT智能服务台 — 审批流程链接模型
# =============================================================================
# 说明:对应数据库 approval_links 表,存储审批流程的外部链接
# 分类:IT/HR/行政/财务
# 在H5用户端右侧AI助手面板中展示,方便员工快速访问审批页面
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ApprovalLink(Base):
"""审批流程链接模型 — 对应 approval_links 表。
存储公司各类审批流程的外部链接,
在H5用户端AI助手面板中按分类展示。
Attributes:
id: 链接唯一标识(UUID,数据库自动生成)
category: 分类(IT/HR/行政/财务)
title: 审批名称
url: 审批链接
sort_order: 排序权重
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "approval_links"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 分类(按部门分类,方便在H5面板中折叠展示)
category: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="分类:IT/HR/行政/财务",
)
# 审批名称(展示给用户看的标题)
title: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="审批名称",
)
# 审批链接(点击后跳转到对应的审批页面)
url: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="审批链接",
)
# 排序权重(同一分类内排序,数值越小越靠前)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按分类查询(如获取所有"IT"分类的审批链接)
Index("idx_al_category", "category"),
)
def __repr__(self) -> str:
"""链接对象的字符串表示,方便调试。"""
return f"<ApprovalLink(id={self.id}, category={self.category}, title={self.title})>"
+95
View File
@@ -0,0 +1,95 @@
# =============================================================================
# 企微IT智能服务台 — 配置变更日志模型
# =============================================================================
# 说明:对应数据库 config_change_logs 表,记录每次配置项的变更历史
# 包含变更前后的值、操作人和时间,用于配置审计和回滚
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ConfigChangeLog(Base):
"""配置变更日志模型 — 对应 config_change_logs 表。
记录每次配置项的变更历史,包含变更前后的值、操作人和时间。
Attributes:
id: 日志唯一标识(UUID)
config_key: 变更的配置键
old_value: 变更前的值
new_value: 变更后的值
changed_by: 变更操作人(agent_id
changed_at: 变更时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "config_change_logs"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 配置键(关联 system_configs 表的 config_key
config_key: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="配置键",
)
# 变更前的值(空字符串表示新增配置项)
old_value: Mapped[str] = mapped_column(
Text,
nullable=False,
default="",
comment="变更前的值",
)
# 变更后的值
new_value: Mapped[str] = mapped_column(
Text,
nullable=False,
default="",
comment="变更后的值",
)
# 变更操作人(关联 agents 表的 id)
changed_by: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="变更操作人 agent_id",
)
# 变更时间
changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="变更时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按配置键查询(如查询某配置项的所有变更历史)
Index("idx_ccl_config_key", "config_key"),
# 按变更时间查询(如查询最近的变更记录)
Index("idx_ccl_changed_at", "changed_at"),
)
def __repr__(self) -> str:
"""变更日志对象的字符串表示,方便调试。"""
return f"<ConfigChangeLog(key={self.config_key}, by={self.changed_by})>"
+292
View File
@@ -0,0 +1,292 @@
# =============================================================================
# 企微IT智能服务台 — 会话模型
# =============================================================================
# 说明:对应数据库 conversations 表,存储所有会话信息
# 核心概念:每个员工的每次咨询对应一个会话(Conversation
# 会话状态流转:ai_handling → queued → serving → resolved
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Conversation(Base):
"""会话模型 — 对应 conversations 表。
每个员工的一次完整咨询过程对应一个会话记录。
包含员工信息、会话状态、紧急度评分、标签等核心数据。
Attributes:
id: 会话唯一标识(UUID,数据库自动生成)
employee_id: 企微员工UserID(关联企微通讯录)
employee_name: 员工姓名(冗余存储,减少关联查询)
department: 员工部门
position: 员工岗位
level: 员工等级(用于 VIP 判断)
status: 会话状态(ai_handling/queued/serving/resolved
is_vip: VIP标记(基于企微通讯录规则自动匹配)
is_pinned: 置顶标记(坐席手动操作)
is_todo: 代办标记(坐席手动操作)
urgency_score: 紧急度评分(1-5,数值越大越紧急)
tags: 标签集合(JSONB,存储举手/需介入/情绪等标记)
assigned_agent_id: 分配的坐席ID
last_message_at: 最后消息时间(用于会话排序)
last_message_summary: 最后消息摘要(会话列表预览用)
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "conversations"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 企业微信企业ID(US-7: 区分主企业和下游企业员工)
# 默认值为主企业 corp_id,下游企业员工使用下游企业 corp_id
corp_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="企业微信企业ID(主企业或下游企业)",
)
# 企微员工UserIDNOT NULL,配合 corp_id 唯一标识员工)
employee_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="企微员工UserID",
)
# 员工姓名(冗余存储,避免每次查询都要关联企微API)
employee_name: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="员工姓名",
)
# 部门
department: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="部门",
)
# 岗位
position: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="岗位",
)
# 等级(用于 VIP 判断:总监及以上为 VIP)
level: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="等级",
)
# 会话状态(CHECK 约束:只能取四种值)
# ai_handling: AI处理中(第二步启用)
# queued: 排队中,等待坐席接入
# serving: 服务中,坐席正在处理
# resolved: 已结单
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="queued",
comment="会话状态: ai_handling/queued/serving/resolved",
)
# VIP标记(基于企微通讯录API规则自动匹配)
is_vip: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="VIP标记",
)
# 置顶标记(坐席手动操作,置顶的会话在列表中优先显示)
is_pinned: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="置顶标记",
)
# 代办标记(坐席手动操作,标记需要后续跟进的会话)
is_todo: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="代办标记",
)
# 紧急度评分(1-5,数值越大越紧急)
# 计算公式:基础分 + 情绪加成 + VIP加成 + 重复追问加成
urgency_score: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
comment="紧急度1-5",
)
# 标签集合(JSON 格式,存储结构化标记数据,兼容所有数据库)
# 示例:{"hand_raise": true, "emotion": "angry", "need_intervene": true}
tags: Mapped[Dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="标签集合",
)
# 分配的坐席ID(可为空,表示尚未分配坐席)
assigned_agent_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="分配的坐席ID",
)
# 协作坐席ID列表(JSON 数组,存储所有被邀请来协作的坐席ID)
# 和 assigned_agent_id 的区别:
# - assigned_agent_id:会话的「主责」坐席(接单人),只有他才能结单/转接
# - collaborating_agent_ids:被邀请来协助的坐席,可以查看和回复,但不能结单
# 设计决策:用 JSON 而非关联表,因为协作人数少(1-3人),JSON 查询足够
collaborating_agent_ids: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="协作坐席ID列表",
)
# 被邀请参与会话的非坐席人员列表(JSON 数组,存储员工ID或部门ID)
# 和 collaborating_agent_ids 的区别:
# - collaborating_agent_ids:坐席 → 坐席协作(摇人)
# - participants:坐席 → 任意员工/部门(邀请功能 P0-09~P0-11
# 格式:[ {"id": "employee_id", "name": "姓名", "department": "部门", "type": "employee"},
# {"id": "dept_id", "name": "部门名", "type": "department"} ]
# 设计决策:存储完整信息,减少企微API调用
participants: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="被邀请参与会话的人员列表(邀请功能)",
)
# AI 实质性回复计数(排除打招呼/呼叫人工的引导回复)
# 当计数 >= 3 时,前端显示「呼叫坐席」按钮
ai_substantive_reply_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="AI实质性回复计数(满3次可呼叫坐席)",
)
# 影响范围(受影响人数,0=未评估,数值越大影响范围越广)
impact_scope: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="影响范围",
)
# 阻断性标记(问题是否阻断员工正常工作流程)
is_blocking: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="阻断性标记",
)
# 情绪状态(normal: 正常, worried: 担忧, angry: 愤怒, urgent: 紧急)
emotion_state: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="normal",
comment="情绪状态",
)
# Dify 会话ID(用于多轮对话上下文保持)
# Dify 侧通过此 ID 关联同一员工的多轮对话
dify_conversation_id: Mapped[Optional[str]] = mapped_column(
String(128),
nullable=True,
default=None,
comment="Dify会话ID(多轮对话上下文)",
)
# 最后消息时间(用于会话列表按最新消息排序)
last_message_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="最后消息时间",
)
# 最后消息摘要(会话列表预览用,截取消息前256字符)
last_message_summary: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="最后消息摘要",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按状态查询(如查询所有排队中的会话)
Index("idx_conversations_status", "status"),
# 按员工ID查询(如查询某个员工的所有会话)
Index("idx_conversations_employee_id", "employee_id"),
# US-7: 按企业ID查询(如查询某企业所有会话)
Index("idx_conversations_corp_id", "corp_id"),
# 按坐席ID查询(如查询某个坐席正在服务的所有会话)
Index("idx_conversations_assigned_agent", "assigned_agent_id"),
# 按紧急度倒序查询(紧急度高的排前面)
Index("idx_conversations_urgency_score", "urgency_score"),
# 按最后消息时间倒序查询(最新消息的排前面)
Index("idx_conversations_last_message_at", "last_message_at"),
)
def __repr__(self) -> str:
"""会话对象的字符串表示,方便调试。"""
return (
f"<Conversation(id={self.id}, employee={self.employee_name}, "
f"status={self.status}, urgency={self.urgency_score})>"
)
+192
View File
@@ -0,0 +1,192 @@
# =============================================================================
# 企微IT智能服务台 — 员工模型
# =============================================================================
# 说明:对应数据库 employees 表,存储通过 OAuth2 认证的员工信息
# US-7 扩展:增加 corp_id 字段支持上下游互联企业场景
# 主企业(亿企赢总部): corp_id = 主企业 corp_id
# 下游企业(亿企赢): corp_id = 下游企业 corp_id
# 复合唯一键 (corp_id, employee_id) 确保跨企业员工标识唯一
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Employee(Base):
"""员工模型 — 对应 employees 表。
通过 OAuth2 认证后记录员工信息,支持跨企业场景(US-7)。
与 Conversation.employee_id 通过 (corp_id, employee_id) 关联。
Attributes:
id: 主键(UUID,数据库自动生成)
corp_id: 企业微信企业ID(主企业或下游企业)
employee_id: 员工UserID(企业内唯一)
name: 员工姓名
department: 部门(JSON数组字符串)
position: 岗位
mobile: 手机号
email: 邮箱
avatar: 头像URL
status: 激活状态(1=已激活, 2=已禁用, 4=未激活)
last_login_at: 最后登录时间
created_at: 创建时间
updated_at: 更新时间
"""
# 表名
__tablename__ = "employees"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
comment="员工记录唯一标识",
)
# 企业微信企业ID(主企业或下游企业)
# US-7: 用于区分不同企业的员工,格式如 "wwa8c87970b2011f41"
corp_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="企业微信企业ID",
)
# 员工UserID(企业内唯一)
# 注意:不同企业的 userid 可能重复,需配合 corp_id 使用
employee_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="企微员工UserID(企业内唯一)",
)
# 员工姓名
name: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="员工姓名",
)
# 部门(JSON数组字符串,如 "[1, 2, 3]"
department: Mapped[str] = mapped_column(
String(512),
nullable=False,
default="",
comment="部门ID列表(JSON数组)",
)
# 岗位
position: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="岗位",
)
# 手机号
mobile: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="手机号",
)
# 邮箱
email: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="邮箱",
)
# 头像URL
avatar: Mapped[str] = mapped_column(
String(512),
nullable=False,
default="",
comment="头像URL",
)
# 激活状态(企微通讯录返回: 1=已激活, 2=已禁用, 4=未激活)
status: Mapped[int] = mapped_column(
default=1,
comment="激活状态: 1=已激活, 2=已禁用, 4=未激活",
)
# IT技能等级(7级: bronze/silver/gold/platinum/diamond/star/king
it_level: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="silver",
comment="IT技能等级",
)
# 等级来源(system: 系统自动评定, manual: 坐席手动调整, assessment: 评估结果)
it_level_source: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="system",
comment="等级来源",
)
# 坐席备注(JSON 格式,存储坐席对员工的备注信息)
notes: Mapped[dict] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="坐席备注",
)
# 最后登录时间
last_login_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="最后登录时间",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引和约束定义
# --------------------------------------------------------------------------
__table_args__ = (
# 复合唯一约束:同一企业内 employee_id 唯一
UniqueConstraint("corp_id", "employee_id", name="uq_employee_corp"),
# 按 corp_id 查询(查询某企业所有员工)
Index("idx_employees_corp_id", "corp_id"),
# 按 employee_id 查询
Index("idx_employees_employee_id", "employee_id"),
)
def __repr__(self) -> str:
"""员工对象的字符串表示。"""
return (
f"<Employee(corp_id={self.corp_id}, employee_id={self.employee_id}, "
f"name={self.name})>"
)
+120
View File
@@ -0,0 +1,120 @@
# =============================================================================
# 企微IT智能服务台 — 趣味话术模型
# =============================================================================
# 说明:对应数据库 funny_phrases 表,存储各场景的趣味话术
# 场景:shake(摇人)/keyword(关键词)/waiting(等待)/connected(接入)/timeout(超时)/vip
# 话术在用户端H5中显示,给等待过程增添趣味性
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class FunnyPhrase(Base):
"""趣味话术模型 — 对应 funny_phrases 表。
按触发场景存储趣味话术,在用户等待过程中显示。
支持后台动态修改,无需发版。
Attributes:
id: 话术唯一标识(UUID,数据库自动生成)
scene: 触发场景(shake/keyword/waiting/connected/timeout/vip
content: 话术内容
tone: 语气标签(亲切/稍正式/安抚/明确交接/降级安抚/正式)
sort_order: 排序权重
is_active: 是否启用
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "funny_phrases"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 触发场景
# shake: 点击摇人按钮时
# keyword: 关键词触发转人工时
# waiting: 排队等待时(30秒无人接单)
# connected: 坐席接入时
# timeout: 等待超时时(2分钟)
# vip: VIP员工时
scene: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="触发场景: shake/keyword/waiting/connected/timeout/vip",
)
# 话术内容
content: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="话术内容",
)
# 语气标签(方便管理员理解话术风格)
tone: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="亲切",
comment="语气标签",
)
# 排序权重(同一场景下有多条话术时,按此排序)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 是否启用(False 的话术不会被使用)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按场景查询(如获取所有"摇人"场景的话术)
Index("idx_fp_scene", "scene"),
)
def __repr__(self) -> str:
"""话术对象的字符串表示,方便调试。"""
return f"<FunnyPhrase(id={self.id}, scene={self.scene}, content={self.content[:20]})>"
+252
View File
@@ -0,0 +1,252 @@
# =============================================================================
# 企微IT智能服务台 — 消息模型
# =============================================================================
# 说明:对应数据库 messages 表,存储会话中的所有消息
# 消息来源:员工(employee)、坐席(agent)、AI(ai)、系统(system)
# 消息类型:文本(text)、图片(image)、文件(file)、语音(voice)、系统提示(system)
# =============================================================================
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Message(Base):
"""消息模型 — 对应 messages 表。
每条消息都属于一个会话(Conversation),记录对话中的每一条信息。
包含发送者信息、消息内容、消息类型等。
Attributes:
id: 消息唯一标识(UUID,数据库自动生成)
conversation_id: 所属会话ID(外键,关联 conversations 表)
sender_type: 发送者类型(employee/agent/ai/system
sender_id: 发送者ID
sender_name: 发送者姓名(冗余存储,减少关联查询)
content: 消息内容(文本消息为文字,媒体消息为描述文字或URL)
msg_type: 消息类型(text/image/file/voice/system
media_id: 企微媒体文件ID(图片/语音/视频消息,3天有效)
media_url: 本地存储的媒体文件URL(下载后保存到服务器)
file_name: 文件名(文件消息用)
file_size: 文件大小(字节)
extra_data: 扩展元数据(JSON,如图片尺寸、语音格式等)
ai_suggestion: 是否为AI建议(坐席端展示用)
status: 消息状态(sending/sent/delivered/read
recallable_until: 可撤回截止时间(创建时间+2分钟)
is_read: 是否已读
created_at: 创建时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "messages"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 所属会话ID(外键,关联 conversations 表)
# ON DELETE CASCADE:删除会话时自动删除该会话的所有消息
conversation_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
comment="所属会话ID",
)
# 发送者类型(CHECK 约束:只能取四种值)
# employee: 员工发送的消息
# agent: 坐席发送的消息
# ai: AI生成的消息(第二步启用)
# system: 系统消息(如"坐席已接入"等通知)
sender_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="发送者类型: employee/agent/ai/system",
)
# 发送者ID
# 员工消息时为企微UserID,坐席消息时为坐席user_id
sender_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="发送者ID",
)
# 发送者姓名(冗余存储,避免每次查消息都要关联用户表)
sender_name: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="发送者姓名",
)
# 消息内容
# 文本消息时为文本内容,图片/文件消息时为媒体URL或描述文字
content: Mapped[str] = mapped_column(
Text,
nullable=False,
default="",
comment="消息内容",
)
# 消息类型(CHECK 约束)
# text: 文本消息
# image: 图片消息
# file: 文件消息
# voice: 语音消息
# system: 系统消息
msg_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="text",
comment="消息类型: text/image/file/voice/system",
)
# 引用回复:指向被回复的消息ID(M1 新增)
# 为 None 时表示普通消息,非 None 时表示对某条消息的回复
# 前端根据此字段显示引用内容(被回复消息的摘要)
reply_to_id: Mapped[Optional[str]] = mapped_column(
String(36),
nullable=True,
default=None,
comment="引用回复:被回复的消息ID",
)
# 企微媒体文件ID(图片/语音/视频消息携带)
# 注意:MediaId 仅3天有效,收到消息后应尽快下载保存到本地
# 下载接口:GET https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=TOKEN&media_id=MEDIA_ID
media_id: Mapped[Optional[str]] = mapped_column(
String(256),
nullable=True,
default=None,
comment="企微媒体文件ID3天有效)",
)
# 本地存储的媒体文件URL(下载后保存到服务器/NAS的访问路径)
# 格式示例:/media/2026/06/03/abc123.jpg
media_url: Mapped[Optional[str]] = mapped_column(
String(512),
nullable=True,
default=None,
comment="本地存储的媒体文件URL",
)
# 文件名(文件消息携带,或下载后自定义的文件名)
file_name: Mapped[Optional[str]] = mapped_column(
String(256),
nullable=True,
default=None,
comment="文件名",
)
# 文件大小(字节,文件消息携带)
file_size: Mapped[Optional[int]] = mapped_column(
Integer,
nullable=True,
default=None,
comment="文件大小(字节)",
)
# 扩展元数据(JSON格式,存储各消息类型的额外信息)
# 示例:
# 图片消息: {"pic_url": "https://...", "width": 1920, "height": 1080}
# 语音消息: {"format": "amr", "duration_seconds": 15}
# 视频消息: {"thumb_media_id": "...", "duration_seconds": 30}
# 位置消息: {"location_x": 23.134, "location_y": 113.358, "label": "杭州市"}
extra_data: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSON,
nullable=True,
default=None,
comment="扩展元数据(JSON",
)
# 是否为AI建议(坐席端展示用)
# True: 此消息为AI建议的回复,坐席可选择采纳/编辑/忽略
# False: 正常消息
ai_suggestion: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否为AI建议",
)
# 消息状态(新增:sending/sent/delivered/read
# sending: 发送中
# sent: 已发送
# delivered: 已送达
# read: 已读
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="sent",
comment="消息状态: sending/sent/delivered/read",
)
# 可撤回截止时间(创时间+2分钟)
# 用于判断消息是否在可撤回时间窗口内
recallable_until: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
default=None,
comment="可撤回截止时间",
)
# 是否已读
# 用于统计未读消息数(坐席端显示红点)
is_read: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否已读",
)
# 坐席对 AI 建议的操作行为
# 仅当 ai_suggestion=True 时有意义
# accepted: 坐席直接采纳了AI建议
# edited: 坐席编辑后采纳了AI建议
# ignored: 坐席忽略了AI建议
suggestion_action: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
default=None,
comment="AI建议操作行为: accepted/edited/ignored",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按会话ID查询(如查询某会话的所有消息)
Index("idx_messages_conversation_id", "conversation_id"),
# 按创建时间查询(如按时间排序消息)
Index("idx_messages_created_at", "created_at"),
# 复合索引:按会话+时间查询(最常见的查询:获取某会话的消息列表)
Index("idx_messages_conversation_created", "conversation_id", "created_at"),
)
def __repr__(self) -> str:
"""消息对象的字符串表示,方便调试。"""
return (
f"<Message(id={self.id}, conv={self.conversation_id}, "
f"from={self.sender_type}, type={self.msg_type})>"
)
+145
View File
@@ -0,0 +1,145 @@
# =============================================================================
# 企微IT智能服务台 — 快速回复模板模型
# =============================================================================
# 说明:对应数据库 quick_reply_templates 表,存储坐席常用回复模板
# 分类:账号/网络/软件/硬件/通用
# 支持变量替换:如 {employee_name} 会被替换为实际员工姓名
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, List
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class QuickReplyTemplate(Base):
"""快速回复模板模型 — 对应 quick_reply_templates 表。
坐席常用回复模板,按分类组织,支持变量替换。
变量如 {employee_name} 在使用时会被替换为实际值。
Attributes:
id: 模板唯一标识(UUID,数据库自动生成)
category: 分类(账号/网络/软件/硬件/通用)
title: 模板标题(简短描述,方便坐席快速识别)
content: 模板内容(支持 {employee_name} 等变量)
variables: 可用变量列表(JSONB,如 ["employee_name","department"]
sort_order: 排序权重(数值越小越靠前)
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "quick_reply_templates"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 分类(用于按类别组织模板,在坐席端折叠展示)
category: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="通用",
comment="分类:账号/网络/软件/硬件/通用",
)
# 模板标题(简短描述,方便坐席快速识别)
title: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="模板标题",
)
# 模板内容(支持变量替换)
# 示例:"您好{employee_name},您的密码重置链接已发送"
content: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="模板内容,支持变量如 {employee_name}",
)
# 可用变量列表(JSON 格式,存储模板中可替换的变量名,兼容所有数据库)
# 示例:["employee_name", "department"]
variables: Mapped[List[str]] = mapped_column(
JSON,
nullable=False,
default=list,
comment="可用变量列表",
)
# 排序权重(数值越小越靠前,同一分类内排序)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 状态(draft=草稿/pending_review=待审核/approved=已通过/rejected=已驳回)
# 审核流转:draft → pending_review → approved / rejected
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="approved",
comment="状态:draft/pending_review/approved/rejected",
)
# 版本号(每次审核通过后 +1
version: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
comment="版本号,每次审核通过后 +1",
)
# 提交人 agent_id(提交审核的坐席ID,可为空表示系统创建)
submitted_by: Mapped[str] = mapped_column(
String(36),
nullable=True,
default=None,
comment="提交人 agent_id",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按分类查询(如获取所有"账号"分类的模板)
Index("idx_qr_category", "category"),
)
def __repr__(self) -> str:
"""模板对象的字符串表示,方便调试。"""
return (
f"<QuickReplyTemplate(id={self.id}, category={self.category}, "
f"title={self.title})>"
)
+91
View File
@@ -0,0 +1,91 @@
# =============================================================================
# 角色模型 — roles 表
# =============================================================================
# 说明:定义系统角色(user/agent/admin),支持 RBAC 权限控制
# =============================================================================
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Boolean, DateTime, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Role(Base):
"""角色模型 — 对应 roles 表。
预置三个角色:
- user: 所有在职员工默认角色(is_default=True
- agent: IT坐席,通过企微标签或eHR字段映射
- admin: 管理员,通过管理后台手动绑定
"""
__tablename__ = "roles"
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 角色标识:user/agent/admin(唯一)
name: Mapped[str] = mapped_column(
String(50),
unique=True,
nullable=False,
comment="角色标识:user/agent/admin",
)
# 显示名称:用户/坐席/管理员
display_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="显示名称:用户/坐席/管理员",
)
# 角色描述
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="角色描述",
)
# 权限列表(JSON数组)
permissions: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="权限列表(JSON数组)",
)
# 是否默认角色(user=True
is_default: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否默认角色(所有员工自动获得)",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间(自动刷新)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
return f"<Role(id={self.id}, name={self.name}, display_name={self.display_name})>"
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# 角色映射规则模型 — role_mapping_rules 表
# =============================================================================
# 说明:定义角色自动映射规则,支持企微标签和eHR字段两种来源
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Integer, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class RoleMappingRule(Base):
"""角色映射规则模型 — 对应 role_mapping_rules 表。
定义自动映射规则,当用户满足条件时自动获得对应角色:
- wecom_tag: 企微标签匹配(如标签包含"IT坐席"
- ehr_position: eHR岗位关键词匹配(如岗位包含"IT支持"
"""
__tablename__ = "role_mapping_rules"
__table_args__ = (
# 按 role_id 查询优化
Index("idx_role_mapping_rules_role_id", "role_id"),
# 按 source_type 查询优化
Index("idx_role_mapping_rules_source_type", "source_type"),
)
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 角色 ID(外键)
role_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("roles.id", ondelete="CASCADE"),
nullable=False,
comment="目标角色 ID",
)
# 来源类型:wecom_tag/ehr_position
source_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="来源类型:wecom_tag/ehr_position",
)
# 来源值:标签名/岗位关键词
source_value: Mapped[str] = mapped_column(
String(200),
nullable=False,
comment="来源值:标签名/岗位关键词",
)
# 优先级(数值越大优先级越高)
priority: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="优先级(数值越大优先级越高)",
)
# 是否启用
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
def __repr__(self) -> str:
return (
f"<RoleMappingRule(id={self.id}, role_id={self.role_id}, "
f"source_type={self.source_type}, source_value={self.source_value})>"
)
+125
View File
@@ -0,0 +1,125 @@
# =============================================================================
# 企微IT智能服务台 — 软件下载入口模型
# =============================================================================
# 说明:对应数据库 software_downloads 表,存储软件下载链接
# 分类:办公/开发/安全/工具
# 在H5用户端右侧AI助手面板中展示,方便员工下载常用软件
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SoftwareDownload(Base):
"""软件下载入口模型 — 对应 software_downloads 表。
存储公司常用软件的下载链接,
在H5用户端AI助手面板中按分类展示。
Attributes:
id: 下载入口唯一标识(UUID,数据库自动生成)
category: 分类(办公/开发/安全/工具)
name: 软件名称
version: 版本号
platform: 平台(Windows/Mac/Linux/全平台)
download_url: 下载链接
sort_order: 排序权重
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "software_downloads"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 分类(按用途分类,方便在H5面板中折叠展示)
category: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="分类:办公/开发/安全/工具",
)
# 软件名称
name: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="软件名称",
)
# 版本号(如 "12.1"、"最新版"
version: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="版本号",
)
# 支持平台(如 "Windows/Mac"、"全平台"
platform: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="平台: Windows/Mac/Linux/全平台",
)
# 下载链接
download_url: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="下载链接",
)
# 排序权重(同一分类内排序,数值越小越靠前)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按分类查询(如获取所有"办公"分类的软件)
Index("idx_sd_category", "category"),
)
def __repr__(self) -> str:
"""下载入口对象的字符串表示,方便调试。"""
return (
f"<SoftwareDownload(id={self.id}, category={self.category}, "
f"name={self.name}, version={self.version})>"
)
+83
View File
@@ -0,0 +1,83 @@
# =============================================================================
# 企微IT智能服务台 — 系统配置模型
# =============================================================================
# 说明:对应数据库 system_configs 表,存储系统级配置项
# 包括:关键词列表、评分阈值、话术模板等
# 优势:配置存在数据库中,支持后台动态修改,无需重启服务
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SystemConfig(Base):
"""系统配置模型 — 对应 system_configs 表。
将可动态修改的配置项存储在数据库中,
支持后台修改后立即生效,无需重启服务。
Attributes:
id: 配置唯一标识(UUID,数据库自动生成)
config_key: 配置键(唯一,如 "hand_raise_keywords"
config_value: 配置值(JSON 字符串或纯文本)
description: 配置说明
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "system_configs"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 配置键(唯一,用于查找配置项)
# 示例:hand_raise_keywords, emotion_keywords_angry, polling_interval_seconds
config_key: Mapped[str] = mapped_column(
String(128),
unique=True,
nullable=False,
comment="配置键",
)
# 配置值(存储 JSON 字符串或纯文本)
# JSON 示例:'["转人工","人工","人工服务"]'
# 纯文本示例:'3'(表示阈值)
config_value: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="配置值(JSON字符串或纯文本)",
)
# 配置说明(方便管理员理解配置用途)
description: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="配置说明",
)
# 更新时间(配置修改时自动更新)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
"""配置对象的字符串表示,方便调试。"""
return f"<SystemConfig(key={self.config_key}, value={self.config_value})>"
+128
View File
@@ -0,0 +1,128 @@
# =============================================================================
# 企微IT智能服务台 — 待办事项模型
# =============================================================================
# 说明:对应数据库 todo_items 表,存储坐席的待办事项
# 待办类型:ticket(工单)/approval(审批)/device(设备) 等
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TodoItem(Base):
"""待办事项模型 — 对应 todo_items 表。
存储坐席需要跟进的各类待办事项,包括工单、审批、设备处理等。
Attributes:
id: 待办唯一标识(UUID,数据库自动生成)
type: 待办类型(ticket/approval/device
title: 待办标题
priority: 优先级(urgent/high/normal
description: 详细描述(JSON,存储结构化数据)
status: 状态(pending/processing/resolved
assigned_agent_id: 分配的坐席ID(可为空,表示未分配)
corp_id: 企业微信企业ID
created_at: 创建时间
updated_at: 更新时间
"""
# 表名
__tablename__ = "todo_items"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
comment="待办唯一标识",
)
# 待办类型
type: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="ticket",
comment="待办类型: ticket/approval/device",
)
# 待办标题
title: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="待办标题",
)
# 优先级
priority: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="normal",
comment="优先级: urgent/high/normal",
)
# 详细描述(JSON 格式,存储结构化数据)
description: Mapped[Dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="详细描述",
)
# 状态
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
comment="状态: pending/processing/resolved",
)
# 分配的坐席ID(可为空,表示未分配)
assigned_agent_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="分配的坐席ID",
)
# 企业微信企业ID
corp_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="企业微信企业ID",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
"""待办事项对象的字符串表示。"""
return (
f"<TodoItem(id={self.id}, type={self.type}, "
f"title={self.title}, status={self.status})>"
)
@@ -0,0 +1,114 @@
# =============================================================================
# 企微IT智能服务台 — 排障模板模型
# =============================================================================
# 说明:对应数据库 troubleshooting_templates 表,存储常见问题的排障模板
# 包含排障步骤路径和流程图定义
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TroubleshootingTemplate(Base):
"""排障模板模型 — 对应 troubleshooting_templates 表。
存储常见 IT 问题的标准化排障模板,包括步骤路径和流程图。
分类:vpn/email/system/account 等。
Attributes:
id: 模板唯一标识(UUID,数据库自动生成)
name: 模板名称
category: 分类(vpn/email/system/account
path_steps: 排障步骤路径(JSON,存储步骤序列)
flowchart: 流程图定义(JSON,存储节点和连线)
is_active: 是否启用
created_at: 创建时间
updated_at: 更新时间
"""
# 表名
__tablename__ = "troubleshooting_templates"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
comment="模板唯一标识",
)
# 模板名称
name: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="模板名称",
)
# 分类
category: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="system",
comment="分类: vpn/email/system/account",
)
# 排障步骤路径(JSON 格式)
# 示例:[{"step": 1, "title": "检查VPN连接状态", "action": "..."}, ...]
path_steps: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="排障步骤路径",
)
# 流程图定义(JSON 格式)
# 示例:{"nodes": [...], "edges": [...]}
flowchart: Mapped[Dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="流程图定义",
)
# 是否启用
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
"""排障模板对象的字符串表示。"""
return (
f"<TroubleshootingTemplate(id={self.id}, name={self.name}, "
f"category={self.category}, is_active={self.is_active})>"
)
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# 用户角色关联模型 — user_roles 表
# =============================================================================
# 说明:用户与角色的多对多关联表,记录角色来源和分配信息
# =============================================================================
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class UserRole(Base):
"""用户角色关联模型 — 对应 user_roles 表。
记录用户拥有的角色,支持以下来源:
- auto: 系统自动分配(所有员工默认 user 角色)
- tag: 企微标签映射
- ehr: eHR 字段映射
- manual: 管理后台手动分配
"""
__tablename__ = "user_roles"
__table_args__ = (
# 同一用户同一角色只能有一条记录
UniqueConstraint("employee_id", "role_id", name="uq_user_role"),
# 按 employee_id 查询优化
Index("idx_user_roles_employee_id", "employee_id"),
# 按 role_id 查询优化
Index("idx_user_roles_role_id", "role_id"),
)
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 企微 UserID
employee_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="企微 UserID",
)
# 角色 ID(外键)
role_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("roles.id", ondelete="CASCADE"),
nullable=False,
comment="角色 ID",
)
# 角色来源:auto/tag/ehr/manual
source: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="角色来源:auto/tag/ehr/manual",
)
# 分配者(手动分配时记录操作人)
assigned_by: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="分配者(手动分配时记录操作人)",
)
# 分配时间
assigned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="分配时间",
)
# 过期时间(可选,用于临时角色)
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="过期时间(可选,用于临时角色)",
)
def __repr__(self) -> str:
return f"<UserRole(employee_id={self.employee_id}, role_id={self.role_id}, source={self.source})>"
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — Schema 包初始化
# =============================================================================
# 说明:导出所有 Pydantic Schema,方便统一导入
# =============================================================================
+492
View File
@@ -0,0 +1,492 @@
# =============================================================================
# 企微IT智能服务台 — 管理后台 Pydantic Schema
# =============================================================================
# 说明:定义管理后台专用请求/响应数据结构
# 包含:仪表盘、配置管理、坐席管理、集成配置、快速回复审核、
# 分配模式、会话监控、全局搜索等全部 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ==========================================================================
# 配置管理 Schema
# ==========================================================================
class ConfigItemResponse(BaseModel):
"""单个配置项响应 Schema。
Attributes:
key: 配置键
value: 配置值
description: 配置说明
value_type: 值类型(boolean/number/string/json_array/json_object
"""
key: str = Field(..., description="配置键")
value: str = Field(..., description="配置值")
description: str = Field(default="", description="配置说明")
value_type: str = Field(default="string", description="值类型: boolean/number/string/json_array/json_object")
model_config = {"from_attributes": True}
class ConfigGroupResponse(BaseModel):
"""配置分组响应 Schema。
按功能前缀将配置项分组,方便前端展示。
Attributes:
name: 分组名称
key_prefix: 配置键前缀
items: 该分组下的配置项列表
"""
name: str = Field(..., description="分组名称")
key_prefix: str = Field(..., description="配置键前缀")
items: List[ConfigItemResponse] = Field(default_factory=list, description="配置项列表")
class ConfigUpdateRequest(BaseModel):
"""配置更新请求 Schema。
Attributes:
value: 新的配置值
"""
value: str = Field(..., min_length=1, description="新的配置值")
class ConfigHistoryItem(BaseModel):
"""配置变更历史条目 Schema。
Attributes:
id: 日志ID
config_key: 配置键
old_value: 变更前的值
new_value: 变更后的值
changed_by: 变更操作人 agent_id
changed_by_name: 变更操作人姓名
changed_at: 变更时间
"""
id: str = Field(..., description="日志ID")
config_key: str = Field(..., description="配置键")
old_value: str = Field(..., description="变更前的值")
new_value: str = Field(..., description="变更后的值")
changed_by: str = Field(..., description="变更操作人 agent_id")
changed_by_name: str = Field(default="", description="变更操作人姓名")
changed_at: datetime = Field(..., description="变更时间")
model_config = {"from_attributes": True}
class ConfigHistoryResponse(BaseModel):
"""配置变更历史响应 Schema。
Attributes:
items: 变更历史条目列表
"""
items: List[ConfigHistoryItem] = Field(default_factory=list, description="变更历史列表")
# ==========================================================================
# 坐席管理 Schema
# ==========================================================================
class AgentCreateRequest(BaseModel):
"""创建坐席请求 Schema。
Attributes:
user_id: 企微用户ID
name: 坐席姓名
role: 角色(admin=组长, agent=坐席)
skill_tags: 技能标签列表
max_load: 最大同时服务数
"""
user_id: str = Field(..., min_length=1, max_length=64, description="企微用户ID")
name: str = Field(..., min_length=1, max_length=128, description="坐席姓名")
role: str = Field(default="agent", description="角色: admin=组长, agent=坐席")
skill_tags: List[str] = Field(default_factory=list, description="技能标签列表")
max_load: int = Field(default=5, ge=1, le=50, description="最大同时服务数")
class AgentUpdateRequest(BaseModel):
"""更新坐席请求 Schema。
所有字段可选,只更新传入的字段。
Attributes:
role: 角色
skill_tags: 技能标签列表
max_load: 最大同时服务数
"""
role: Optional[str] = Field(None, description="角色: admin=组长, agent=坐席")
skill_tags: Optional[List[str]] = Field(None, description="技能标签列表")
max_load: Optional[int] = Field(None, ge=1, le=50, description="最大同时服务数")
class AdminAgentResponse(BaseModel):
"""管理后台坐席响应 Schema(含角色/技能标签/今日结单数)。
Attributes:
id: 坐席ID
user_id: 企微用户ID
name: 坐席姓名
status: 坐席状态
role: 角色
skill_tags: 技能标签列表
current_load: 当前服务会话数
max_load: 最大同时服务数
today_resolved: 今日结单数
created_at: 创建时间
updated_at: 更新时间
"""
id: str
user_id: str
name: str
status: str
role: str = "agent"
skill_tags: List[str] = []
current_load: int = 0
max_load: int = 5
today_resolved: int = 0
otp_secret: Optional[str] = None
otp_enabled: bool = False
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# =========================================================================
# 集成配置 Schema
# =========================================================================
class IntegrationConfig(BaseModel):
"""集成系统配置 Schema(通用,支持 url_key 和 access_key 两种模式)。
Attributes:
# url_key 模式(Dify / RAGFlow
api_url: API 地址
api_key_set: API Key 是否已设置
# access_key 模式(火绒安全)
access_key_id_set: AccessKey ID 是否已设置
access_key_secret_set: AccessKey Secret 是否已设置
base_url: 内网 Base URL
"""
# url_key 模式(Dify / RAGFlow
api_url: str = Field(default="", description="API 地址")
api_key_set: bool = Field(default=False, description="API Key 是否已设置")
# access_key 模式(火绒安全)
access_key_id_set: bool = Field(default=False, description="AccessKey ID 是否已设置")
access_key_secret_set: bool = Field(default=False, description="AccessKey Secret 是否已设置")
base_url: Optional[str] = Field(default=None, description="内网 Base URL")
# account_password 模式(联软LV7000
api_account_set: bool = Field(default=False, description="API账号是否已设置")
api_password_set: bool = Field(default=False, description="API密码是否已设置")
class IntegrationResponse(BaseModel):
"""集成系统响应 Schema。
Attributes:
id: 集成系统ID(如 dify/ragflow/huorong
name: 集成系统名称
status: 连接状态(connected/partial/disconnected/pending
configurable: 是否可配置
config_type: 配置类型(url_key/access_key),前端据此显示不同表单
config: 配置信息(不可配置时为 None)
"""
id: str = Field(..., description="集成系统ID")
name: str = Field(..., description="集成系统名称")
status: str = Field(default="disconnected", description="连接状态: connected/partial/disconnected/pending")
configurable: bool = Field(default=False, description="是否可配置")
config_type: Optional[str] = Field(default=None, description="配置类型: url_key/access_key/account_password")
config: Optional[IntegrationConfig] = Field(default=None, description="配置信息")
class IntegrationUpdateRequest(BaseModel):
"""集成系统配置更新请求 Schema。
支持三种模式:
- url_key 模式(Dify / RAGFlow):传入 api_url + api_key
- access_key 模式(火绒安全):传入 access_key_id + access_key_secret + base_url
- account_password 模式(联软LV7000):传入 api_account + api_password + base_url + validate_key(可选)
Attributes:
# url_key 模式
api_url: API 地址(可选,火绒/联软模式不需要)
api_key: API Key(可选,火绒/联软模式不需要)
# access_key 模式(火绒)
access_key_id: AccessKey ID(可选)
access_key_secret: AccessKey Secret(可选)
base_url: 内网 Base URL(可选)
# account_password 模式(联软)
api_account: API账号(可选,联软模式)
api_password: API密码(可选,联软模式)
validate_key: 验证密钥(可选,联软模式)
"""
# url_key 模式(Dify / RAGFlow
api_url: Optional[str] = Field(default=None, description="API 地址(Dify/RAGFlow 模式)")
api_key: Optional[str] = Field(default=None, description="API KeyDify/RAGFlow 模式)")
# access_key 模式(火绒安全)
access_key_id: Optional[str] = Field(default=None, description="AccessKey ID(火绒模式)")
access_key_secret: Optional[str] = Field(default=None, description="AccessKey Secret(火绒模式)")
base_url: Optional[str] = Field(default=None, description="内网 Base URL(火绒/联软模式)")
# account_password 模式(联软LV7000
api_account: Optional[str] = Field(default=None, description="API账号(联软模式)")
api_password: Optional[str] = Field(default=None, description="API密码(联软模式)")
validate_key: Optional[str] = Field(default=None, description="验证密钥(联软模式,可选)")
# ==========================================================================
# 快速回复审核 Schema
# ==========================================================================
class QuickReplyReviewRequest(BaseModel):
"""快速回复审核请求 Schema。
Attributes:
action: 审核动作(approve=通过, reject=驳回)
reason: 审核原因/意见(驳回时建议填写)
"""
action: str = Field(..., description="审核动作: approve/reject")
reason: str = Field(default="", description="审核原因/意见")
class AdminQuickReplyResponse(BaseModel):
"""管理后台快速回复响应 Schema(含审核信息)。
Attributes:
id: 模板ID
category: 分类
title: 模板标题
content: 模板内容
variables: 可用变量列表
status: 状态
version: 版本号
submitted_by: 提交人 agent_id
submitted_by_name: 提交人姓名
sort_order: 排序权重
created_at: 创建时间
updated_at: 更新时间
"""
id: str
category: str
title: str
content: str
variables: List[str]
status: str = "approved"
version: int = 1
submitted_by: Optional[str] = None
submitted_by_name: str = ""
sort_order: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ==========================================================================
# 分配模式 Schema
# ==========================================================================
class AssignmentModeItem(BaseModel):
"""分配模式条目 Schema。
Attributes:
id: 模式ID
name: 模式名称
enabled: 是否启用
locked: 是否锁定(阶段一锁定部分模式)
unlock_at: 解锁阶段说明
"""
id: str = Field(..., description="模式ID")
name: str = Field(..., description="模式名称")
enabled: bool = Field(default=False, description="是否启用")
locked: bool = Field(default=True, description="是否锁定")
unlock_at: str = Field(default="", description="解锁阶段说明")
class AssignmentModeResponse(BaseModel):
"""分配模式响应 Schema。
Attributes:
current_mode: 当前启用的分配模式
modes: 所有分配模式列表
"""
current_mode: str = Field(default="manual", description="当前分配模式")
modes: List[AssignmentModeItem] = Field(default_factory=list, description="分配模式列表")
class AssignmentModeUpdateRequest(BaseModel):
"""分配模式更新请求 Schema。
Attributes:
mode: 要切换的分配模式ID
"""
mode: str = Field(..., description="分配模式ID")
# ==========================================================================
# 仪表盘 Schema
# ==========================================================================
class SystemAlertItem(BaseModel):
"""单条系统告警 Schema。
与前端 SystemAlert 接口对齐,支持结构化告警展示。
Attributes:
type: 告警类型(如 quick_reply_pending / agent_offline / system_error
content: 告警内容描述
submitter: 提交人姓名(仅快速回复待审核类告警有值)
time: 告警发生时间(ISO 8601 格式)
severity: 严重程度(info/warning/critical
"""
type: str = Field(..., description="告警类型")
content: str = Field(..., description="告警内容")
submitter: Optional[str] = Field(default=None, description="提交人")
time: str = Field(..., description="告警时间")
severity: str = Field(default="info", description="严重程度: info/warning/critical")
class IntegrationHealthItem(BaseModel):
"""集成系统健康状态条目 Schema。
Attributes:
system: 系统名称
status: 连接状态
"""
system: str = Field(..., description="系统名称")
status: str = Field(default="disconnected", description="连接状态")
class DashboardOverviewResponse(BaseModel):
"""仪表盘总览响应 Schema。
Attributes:
online_agents: 在线坐席数
today_conversations: 今日会话数
avg_response_time: 平均响应时间(阶段一占位)
ai_hit_rate: AI 命中率(阶段一占位)
pending_reviews: 待审核快速回复数
system_alerts: 系统告警列表
integrations_health: 集成系统健康状态
"""
online_agents: int = Field(default=0, description="在线坐席数")
today_conversations: int = Field(default=0, description="今日会话数")
avg_response_time: str = Field(default="", description="平均响应时间(阶段一占位)")
ai_hit_rate: str = Field(default="", description="AI命中率(阶段一占位)")
pending_reviews: int = Field(default=0, description="待审核快速回复数")
system_alerts: List[SystemAlertItem] = Field(default_factory=list, description="系统告警列表")
integrations_health: List[IntegrationHealthItem] = Field(default_factory=list, description="集成系统健康状态")
# ==========================================================================
# 会话监控 Schema
# ==========================================================================
class SessionStats(BaseModel):
"""会话统计 Schema。
Attributes:
in_progress: 服务中会话数
queued: 排队中会话数
resolved_today: 今日已结单数
alerts: 告警数
"""
in_progress: int = Field(default=0, description="服务中会话数")
queued: int = Field(default=0, description="排队中会话数")
resolved_today: int = Field(default=0, description="今日已结单数")
alerts: int = Field(default=0, description="告警数")
class SessionItem(BaseModel):
"""会话条目 Schema(监控列表用)。
Attributes:
id: 会话ID
employee_name: 员工姓名
status: 会话状态
assigned_agent_name: 负责坐席姓名
urgency_score: 紧急度评分
created_at: 创建时间
last_message_summary: 最后消息摘要
"""
id: str = Field(..., description="会话ID")
employee_name: str = Field(default="", description="员工姓名")
status: str = Field(default="queued", description="会话状态")
assigned_agent_name: str = Field(default="", description="负责坐席姓名")
urgency_score: int = Field(default=1, description="紧急度评分")
created_at: datetime = Field(..., description="创建时间")
last_message_summary: str = Field(default="", description="最后消息摘要")
class MonitorSessionsResponse(BaseModel):
"""会话监控响应 Schema。
Attributes:
stats: 会话统计
items: 会话列表
"""
stats: SessionStats = Field(..., description="会话统计")
items: List[SessionItem] = Field(default_factory=list, description="会话列表")
# ==========================================================================
# 全局搜索 Schema
# ==========================================================================
class SearchItem(BaseModel):
"""搜索结果条目 Schema。
Attributes:
type: 结果类型(config/agent/quick_reply
id: 对象ID
name: 对象名称/标题
route: 前端路由路径
"""
type: str = Field(..., description="结果类型: config/agent/quick_reply")
id: str = Field(..., description="对象ID")
name: str = Field(..., description="对象名称/标题")
route: str = Field(..., description="前端路由路径")
class SearchResponse(BaseModel):
"""搜索结果响应 Schema。
Attributes:
items: 搜索结果列表
"""
items: List[SearchItem] = Field(default_factory=list, description="搜索结果列表")
+111
View File
@@ -0,0 +1,111 @@
# =============================================================================
# 企微IT智能服务台 — 坐席 Pydantic Schema
# =============================================================================
# 说明:定义坐席相关的请求/响应数据结构
# 包含:登录、状态更新、响应三种 Schema
# =============================================================================
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 坐席状态合法值
# --------------------------------------------------------------------------
VALID_AGENT_STATUSES = {"online", "offline", "busy"}
# --------------------------------------------------------------------------
# 坐席登录 Schema
# --------------------------------------------------------------------------
class AgentLogin(BaseModel):
"""坐席登录请求 Schema。
第一步使用简单的用户名密码登录。
user_id 对应企微通讯录中的 UserID。
admin 角色需要 OTP 二次验证。
Attributes:
user_id: 企微用户ID
name: 坐席姓名
otp_code: OTP 动态码(admin 角色必填)
"""
user_id: str = Field(..., min_length=1, max_length=64, description="企微用户ID")
name: str = Field(..., min_length=1, max_length=128, description="坐席姓名")
otp_code: Optional[str] = Field(None, min_length=6, max_length=6, description="OTP动态码(6位数字)")
# --------------------------------------------------------------------------
# 坐席状态更新 Schema
# --------------------------------------------------------------------------
class AgentStatusUpdate(BaseModel):
"""坐席状态更新请求 Schema。
坐席上线、离线、设为忙碌时使用。
Attributes:
status: 新的坐席状态
"""
status: str = Field(..., description="坐席状态: online/offline/busy")
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
"""校验坐席状态值是否合法。"""
if v not in VALID_AGENT_STATUSES:
raise ValueError(f"无效的坐席状态: {v},合法值为: {VALID_AGENT_STATUSES}")
return v
# --------------------------------------------------------------------------
# 坐席响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class AgentResponse(BaseModel):
"""坐席响应 Schema。
返回给前端的坐席数据结构。
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换。
Attributes:
id: 坐席ID
user_id: 企微用户ID
name: 坐席姓名
status: 坐席状态
role: 角色(admin=组长, agent=坐席)
skill_tags: 技能标签列表
current_load: 当前服务会话数
max_load: 最大同时服务数
created_at: 创建时间
updated_at: 更新时间
"""
id: str
user_id: str
name: str
status: str
role: str = "agent"
skill_tags: List[str] = []
current_load: int
max_load: int
otp_enabled: bool = False # OTP 是否已启用
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 坐席列表响应 Schema
# --------------------------------------------------------------------------
class AgentListResponse(BaseModel):
"""坐席列表响应 Schema。
Attributes:
items: 坐席列表
"""
items: List[AgentResponse]
+319
View File
@@ -0,0 +1,319 @@
# =============================================================================
# 企微IT智能服务台 — 会话 Pydantic Schema
# =============================================================================
# 说明:定义会话相关的请求/响应数据结构
# 包含:创建、更新、响应三种 Schema
# tags 字段使用 JSONB 结构,定义了详细的子结构
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# tags JSONB 字段的子结构定义
# --------------------------------------------------------------------------
class ConversationTags(BaseModel):
"""会话标签集合 — 对应 conversations.tags JSONB 字段。
记录会话的各种标记状态,用于坐席端展示和排序。
Attributes:
hand_raise: 举手标记(员工说"转人工"或点击摇人按钮)
need_intervene: 需介入标记(追问超过N轮)
emotion: 情绪标记(neutral/worried/angry/urgent
emotion_keywords: 触发情绪标记的关键词列表
repeat_count: 追问轮次计数
"""
# 举手标记(员工明确要求转人工)
hand_raise: bool = Field(default=False, description="举手标记")
# 需介入标记(同一问题追问超过阈值)
need_intervene: bool = Field(default=False, description="需介入标记")
# 情绪标记(neutral: 正常, worried: 担忧, angry: 愤怒, urgent: 紧急)
emotion: str = Field(
default="neutral",
description="情绪标记: neutral/worried/angry/urgent",
)
# 触发情绪标记的关键词列表
emotion_keywords: List[str] = Field(
default_factory=list,
description="触发情绪标记的关键词",
)
# 追问轮次计数(同一会话中员工连续追问的次数)
repeat_count: int = Field(default=0, description="追问轮次计数")
# --------------------------------------------------------------------------
# 会话状态枚举值校验
# --------------------------------------------------------------------------
VALID_STATUSES = {"ai_handling", "queued", "serving", "resolved"}
# --------------------------------------------------------------------------
# 创建会话 Schema(从企微消息创建会话时使用)
# --------------------------------------------------------------------------
class ConversationCreate(BaseModel):
"""创建会话请求 Schema。
从企微消息回调创建新会话时使用,
只需要员工ID和姓名,其他信息后续补充。
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
department: 部门
position: 岗位
level: 等级
"""
employee_id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID")
employee_name: str = Field(default="", max_length=128, description="员工姓名")
department: str = Field(default="", max_length=256, description="部门")
position: str = Field(default="", max_length=128, description="岗位")
level: str = Field(default="", max_length=64, description="等级")
# --------------------------------------------------------------------------
# 更新会话 Schema(坐席修改会话信息时使用)
# --------------------------------------------------------------------------
class ConversationUpdate(BaseModel):
"""更新会话请求 Schema。
坐席更新会话信息时使用,所有字段可选(只更新传入的字段)。
Attributes:
employee_name: 员工姓名
department: 部门
position: 岗位
level: 等级
is_vip: VIP标记
tags: 标签集合
urgency_score: 紧急度评分
assigned_agent_id: 分配的坐席ID
last_message_summary: 最后消息摘要
"""
employee_name: Optional[str] = Field(None, max_length=128, description="员工姓名")
department: Optional[str] = Field(None, max_length=256, description="部门")
position: Optional[str] = Field(None, max_length=128, description="岗位")
level: Optional[str] = Field(None, max_length=64, description="等级")
is_vip: Optional[bool] = Field(None, description="VIP标记")
tags: Optional[ConversationTags] = Field(None, description="标签集合")
urgency_score: Optional[int] = Field(None, ge=1, le=5, description="紧急度1-5")
assigned_agent_id: Optional[str] = Field(None, max_length=64, description="分配的坐席ID")
last_message_summary: Optional[str] = Field(None, max_length=256, description="最后消息摘要")
# --------------------------------------------------------------------------
# 更新会话状态 Schema
# --------------------------------------------------------------------------
class ConversationStatusUpdate(BaseModel):
"""更新会话状态请求 Schema。
Attributes:
status: 新的会话状态
"""
status: str = Field(..., description="会话状态: ai_handling/queued/serving/resolved")
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
"""校验会话状态值是否合法。"""
if v not in VALID_STATUSES:
raise ValueError(f"无效的会话状态: {v},合法值为: {VALID_STATUSES}")
return v
# --------------------------------------------------------------------------
# 坐席接单 Schema
# --------------------------------------------------------------------------
class ConversationAssign(BaseModel):
"""坐席接单请求 Schema。
Attributes:
agent_id: 接单的坐席ID
"""
agent_id: str = Field(..., min_length=1, max_length=64, description="坐席ID")
# --------------------------------------------------------------------------
# 摇人(邀请协作)Schema
# --------------------------------------------------------------------------
class ConversationInvite(BaseModel):
"""摇人邀请请求 Schema。
Attributes:
agent_id: 被邀请的坐席ID
"""
agent_id: str = Field(..., min_length=1, max_length=64, description="被邀请的坐席ID")
# --------------------------------------------------------------------------
# 邀请员工/部门加入会话 SchemaP0-09~P0-11 邀请功能)
# --------------------------------------------------------------------------
class ParticipantInfo(BaseModel):
"""被邀请人信息。
Attributes:
id: 企微员工UserID 或部门ID
name: 姓名 或 部门名称
department: 部门(仅员工类型有)
type: 类型 — employee(个人)或 department(部门)
avatar: 头像URL(从企微通讯录或employees表获取)
joined: 是否已加入(邀请后、点击加入前为 False)
joined_at: 加入时间(ISO 格式字符串)
"""
id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID或部门ID")
name: str = Field(..., min_length=1, max_length=128, description="姓名或部门名称")
department: str = Field(default="", max_length=256, description="部门(仅员工类型)")
type: str = Field(default="employee", description="类型: employee/department")
avatar: str = Field(default="", max_length=512, description="头像URL")
joined: Optional[bool] = Field(default=None, description="是否已加入")
joined_at: Optional[str] = Field(default=None, description="加入时间(ISO格式)")
class InviteParticipantRequest(BaseModel):
"""邀请员工/部门加入会话请求 Schema。
Attributes:
participants: 被邀请人列表
history_mode: 历史消息共享模式 — recent10(最近10条)/ all(全部)/ none(不共享)
"""
participants: List[ParticipantInfo] = Field(
..., min_length=1, max_length=20, description="被邀请人列表"
)
history_mode: str = Field(
default="recent10",
description="历史消息共享模式: recent10/all/none",
)
@field_validator("history_mode")
@classmethod
def validate_history_mode(cls, v: str) -> str:
"""校验历史共享模式。"""
valid_modes = {"recent10", "all", "none"}
if v not in valid_modes:
raise ValueError(f"无效的历史共享模式: {v},合法值为: {valid_modes}")
return v
@field_validator("participants")
@classmethod
def validate_participants_unique(cls, v: List[ParticipantInfo]) -> List[ParticipantInfo]:
"""校验参与者ID不重复。"""
ids = [p.id for p in v]
if len(ids) != len(set(ids)):
raise ValueError("参与者ID不能重复")
return v
class JoinConversationRequest(BaseModel):
"""被邀请人加入会话请求 Schema。
Attributes:
employee_id: 被邀请人的企微UserID
"""
employee_id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID")
# --------------------------------------------------------------------------
# 会话响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class ConversationResponse(BaseModel):
"""会话响应 Schema。
返回给前端(坐席端/H5端)的会话数据结构。
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换。
Attributes:
id: 会话ID
employee_id: 企微员工UserID
employee_name: 员工姓名
department: 部门
position: 岗位
level: 等级
status: 会话状态
is_vip: VIP标记
is_pinned: 置顶标记
is_todo: 代办标记
urgency_score: 紧急度评分
tags: 标签集合
assigned_agent_id: 分配的坐席ID
collaborating_agent_ids: 协作坐席ID列表
last_message_at: 最后消息时间
last_message_summary: 最后消息摘要
created_at: 创建时间
updated_at: 更新时间
"""
id: str
employee_id: str
employee_name: str
department: str
position: str
level: str
status: str
is_vip: bool
is_pinned: bool
is_todo: bool
urgency_score: int
tags: Dict[str, Any]
assigned_agent_id: Optional[str] = None
collaborating_agent_ids: List[str] = Field(default_factory=list, description="协作坐席ID列表")
# 被邀请参与会话的人员列表(邀请功能 P0-09~P0-11
participants: List[ParticipantInfo] = Field(default_factory=list, description="被邀请参与会话的人员列表")
last_message_at: Optional[datetime] = None
last_message_summary: str
created_at: datetime
updated_at: datetime
# ----- 坐席会话全局可见扩展字段 -----
# 是否为当前坐席的会话
is_mine: bool = Field(default=False, description="是否为当前坐席的会话")
# 分配的坐席姓名(其他坐席会话显示用)
assigned_agent_name: Optional[str] = Field(default=None, description="分配的坐席姓名")
# 是否可以接手(其他坐席已接单的会话为 True)
can_grab: bool = Field(default=False, description="是否可以接手")
# ----- 多坐席协作扩展字段 -----
# 协作坐席姓名映射(agent_id → name
collaborating_agent_names: Dict[str, str] = Field(
default_factory=dict, description="协作坐席姓名映射"
)
# 当前坐席是否为协作坐席(非主责)
is_collaborator: bool = Field(default=False, description="是否为协作坐席")
# ----- v5.3 新增:影响范围 / 阻断性 / 情绪状态 -----
# 影响范围(受影响人数,0=未评估)
impact_scope: int = Field(default=0, description="影响范围")
# 阻断性标记(问题是否阻断员工正常工作流程)
is_blocking: bool = Field(default=False, description="阻断性标记")
# 情绪状态(normal/worried/angry/urgent
emotion_state: str = Field(default="normal", description="情绪状态")
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 会话列表响应 Schema(包含分页信息)
# --------------------------------------------------------------------------
class ConversationListResponse(BaseModel):
"""会话列表响应 Schema。
包含会话列表和总数,用于分页查询。
Attributes:
items: 会话列表
total: 总数
"""
items: List[ConversationResponse]
total: int
+118
View File
@@ -0,0 +1,118 @@
# =============================================================================
# 企微IT智能服务台 — 员工 Pydantic Schema
# =============================================================================
# 说明:定义员工相关的请求/响应数据结构
# 包含:IT等级更新请求、员工响应 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# IT 等级合法值
# --------------------------------------------------------------------------
VALID_IT_LEVELS = {"bronze", "silver", "gold", "platinum", "diamond", "star", "king"}
# --------------------------------------------------------------------------
# 等级来源合法值
# --------------------------------------------------------------------------
VALID_LEVEL_SOURCES = {"system", "manual", "assessment"}
# --------------------------------------------------------------------------
# IT 等级更新请求 Schema
# --------------------------------------------------------------------------
class ItLevelUpdateRequest(BaseModel):
"""IT技能等级更新请求 Schema。
坐席手动调整员工IT技能等级时使用。
Attributes:
it_level: 新的IT技能等级
source: 等级来源(默认 manual
"""
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
@field_validator("it_level")
@classmethod
def validate_it_level(cls, v: str) -> str:
"""校验IT等级值是否合法。"""
if v not in VALID_IT_LEVELS:
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
return v
@field_validator("source")
@classmethod
def validate_source(cls, v: str) -> str:
"""校验等级来源值是否合法。"""
if v not in VALID_LEVEL_SOURCES:
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
return v
# --------------------------------------------------------------------------
# 员工响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class EmployeeResponse(BaseModel):
"""员工响应 Schema。
返回给前端的员工数据结构。
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换。
Attributes:
id: 员工记录唯一标识
corp_id: 企业微信企业ID
employee_id: 企微员工UserID
name: 员工姓名
department: 部门
position: 岗位
mobile: 手机号
email: 邮箱
avatar: 头像URL
status: 激活状态
it_level: IT技能等级
it_level_source: 等级来源
notes: 坐席备注
last_login_at: 最后登录时间
created_at: 创建时间
updated_at: 更新时间
"""
id: str
corp_id: str
employee_id: str
name: str
department: str
position: str
mobile: str
email: str
avatar: str
status: int
it_level: str = "silver"
it_level_source: str = "system"
notes: Dict[str, Any] = Field(default_factory=dict, description="坐席备注")
last_login_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 员工列表响应 Schema
# --------------------------------------------------------------------------
class EmployeeListResponse(BaseModel):
"""员工列表响应 Schema。
Attributes:
items: 员工列表
total: 总数
"""
items: List[EmployeeResponse]
total: int
+209
View File
@@ -0,0 +1,209 @@
# =============================================================================
# 企微IT智能服务台 — H5 用户端 Pydantic Schema
# =============================================================================
# 说明:定义H5用户端专用的请求/响应数据结构
# 包含:摇人请求、OAuth回调、审批链接、软件下载、员工信息等
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# --------------------------------------------------------------------------
# 摇人请求 Schema
# --------------------------------------------------------------------------
class ShakeRequest(BaseModel):
"""摇人请求 Schema。
用户点击H5页面摇人按钮时发送的请求。
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
"""
employee_id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID")
employee_name: str = Field(default="", max_length=128, description="员工姓名")
# --------------------------------------------------------------------------
# 摇人响应 Schema
# --------------------------------------------------------------------------
class ShakeResponse(BaseModel):
"""摇人响应 Schema。
摇人成功后返回会话信息和趣味话术。
Attributes:
conversation: 会话信息(包含ID、状态、标签)
funny_phrase: 趣味话术内容
"""
conversation: Dict[str, Any] = Field(..., description="会话信息")
funny_phrase: str = Field(..., description="趣味话术")
# --------------------------------------------------------------------------
# OAuth2 回调请求 Schema
# --------------------------------------------------------------------------
class OAuthCallbackRequest(BaseModel):
"""OAuth2 回调请求 Schema。
H5页面通过企微OAuth2授权后,将code传给后端换取员工身份。
Attributes:
code: 企微OAuth2授权码
"""
code: str = Field(..., min_length=1, description="企微OAuth2授权码")
# --------------------------------------------------------------------------
# OAuth2 回调响应 Schema
# --------------------------------------------------------------------------
class OAuthCallbackResponse(BaseModel):
"""OAuth2 回调响应 Schema。
用授权码换取到的员工身份信息和访问令牌。
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
token: 访问令牌(用于后续API请求的Bearer Token
department: 部门名称
position: 岗位
avatar: 头像URL
"""
employee_id: str = Field(..., description="企微员工UserID")
employee_name: str = Field(default="", description="员工姓名")
token: str = Field(..., description="访问令牌")
department: str = Field(default="", description="部门名称")
position: str = Field(default="", description="岗位")
avatar: str = Field(default="", description="头像URL")
# --------------------------------------------------------------------------
# OAuth2 授权URL响应 Schema
# --------------------------------------------------------------------------
class OAuthAuthorizeResponse(BaseModel):
"""OAuth2 授权URL响应 Schema。
返回企微OAuth2授权链接,前端跳转到此URL进行授权。
Attributes:
authorize_url: 企微OAuth2授权URL
"""
authorize_url: str = Field(..., description="企微OAuth2授权URL")
# --------------------------------------------------------------------------
# 员工信息 Schema
# --------------------------------------------------------------------------
class EmployeeInfo(BaseModel):
"""员工信息 Schema。
从企微通讯录获取的员工详细信息。
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
department: 部门名称(逗号分隔)
position: 岗位
mobile: 手机号
email: 邮箱
avatar: 头像URL
is_vip: 是否VIP员工
"""
employee_id: str = Field(..., description="企微员工UserID")
employee_name: str = Field(default="", description="员工姓名")
department: str = Field(default="", description="部门名称")
position: str = Field(default="", description="岗位")
mobile: str = Field(default="", description="手机号")
email: str = Field(default="", description="邮箱")
avatar: str = Field(default="", description="头像URL")
is_vip: bool = Field(default=False, description="是否VIP员工")
# --------------------------------------------------------------------------
# 审批链接响应 Schema
# --------------------------------------------------------------------------
class ApprovalLinkResponse(BaseModel):
"""审批链接响应 Schema。
H5用户端AI助手面板中的审批流程链接。
Attributes:
id: 链接ID
category: 分类
title: 审批名称
url: 审批链接
sort_order: 排序权重
"""
id: str
category: str
title: str
url: str
sort_order: int
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 软件下载响应 Schema
# --------------------------------------------------------------------------
class SoftwareDownloadResponse(BaseModel):
"""软件下载响应 Schema。
H5用户端AI助手面板中的软件下载入口。
Attributes:
id: 下载入口ID
category: 分类
name: 软件名称
version: 版本号
platform: 平台
download_url: 下载链接
sort_order: 排序权重
"""
id: str
category: str
name: str
version: str
platform: str
download_url: str
sort_order: int
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 审批链接列表响应 Schema
# --------------------------------------------------------------------------
class ApprovalLinkListResponse(BaseModel):
"""审批链接列表响应 Schema。
Attributes:
items: 审批链接列表
"""
items: List[ApprovalLinkResponse]
# --------------------------------------------------------------------------
# 软件下载列表响应 Schema
# --------------------------------------------------------------------------
class SoftwareDownloadListResponse(BaseModel):
"""软件下载列表响应 Schema。
Attributes:
items: 软件下载列表
"""
items: List[SoftwareDownloadResponse]
+145
View File
@@ -0,0 +1,145 @@
# =============================================================================
# 企微IT智能服务台 — 消息 Pydantic Schema
# =============================================================================
# 说明:定义消息相关的请求/响应数据结构
# 支持消息类型:文本(text)/图片(image)/文件(file)/系统(system)
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 消息类型和发送者类型的合法值
# --------------------------------------------------------------------------
VALID_MSG_TYPES = {"text", "image", "file", "system"}
VALID_SENDER_TYPES = {"employee", "agent", "ai", "system"}
# --------------------------------------------------------------------------
# 创建消息 Schema(坐席发送消息时使用)
# --------------------------------------------------------------------------
class MessageCreate(BaseModel):
"""创建消息请求 Schema。
坐席发送消息时使用。
支持文本消息和文件/图片消息。
Attributes:
content: 消息内容(文本消息为正文,文件消息为文件URL或描述)
msg_type: 消息类型(默认 text,支持 image/file
media_url: 媒体文件URL(图片/文件消息时使用)
file_name: 文件名(文件消息时使用)
file_size: 文件大小(字节,文件消息时使用)
"""
content: str = Field(..., min_length=1, description="消息内容")
# 支持文本、图片、文件类型
msg_type: str = Field(default="text", description="消息类型: text/image/file")
# M1 新增:文件上传相关字段
media_url: Optional[str] = Field(None, description="媒体文件URL(图片/文件消息时使用)")
file_name: Optional[str] = Field(None, description="文件名")
file_size: Optional[int] = Field(None, description="文件大小(字节)")
# M1 新增:引用回复
reply_to_id: Optional[str] = Field(None, description="引用回复:被回复的消息ID")
@field_validator("msg_type")
@classmethod
def validate_msg_type(cls, v: str) -> str:
"""校验消息类型是否合法。"""
if v not in VALID_MSG_TYPES:
raise ValueError(f"无效的消息类型: {v},合法值为: {VALID_MSG_TYPES}")
return v
# --------------------------------------------------------------------------
# 企微回调消息 Schema(从企微接收到的消息)
# --------------------------------------------------------------------------
class WecomInboundMessage(BaseModel):
"""企微回调消息 Schema。
解析企微回调 XML 后得到的结构化消息。
Attributes:
from_user_id: 发送者企微UserID
content: 消息内容
msg_type: 消息类型(text/image等)
create_time: 消息创建时间戳
agent_id: 应用AgentID
"""
from_user_id: str = Field(..., description="发送者企微UserID")
content: str = Field(default="", description="消息内容")
msg_type: str = Field(default="text", description="消息类型")
create_time: Optional[int] = Field(None, description="消息创建时间戳")
agent_id: Optional[str] = Field(None, description="应用AgentID")
# --------------------------------------------------------------------------
# 消息响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class MessageResponse(BaseModel):
"""消息响应 Schema。
返回给前端的消息数据结构。
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换。
Attributes:
id: 消息ID
conversation_id: 所属会话ID
sender_type: 发送者类型
sender_id: 发送者ID
sender_name: 发送者姓名
content: 消息内容
msg_type: 消息类型
media_url: 媒体文件URL
file_name: 文件名
file_size: 文件大小
extra_data: 扩展元数据
ai_suggestion: 是否为AI建议
is_read: 是否已读
created_at: 创建时间
"""
id: str
conversation_id: str
sender_type: str
sender_id: str
sender_name: str
content: str
msg_type: str
# M1 新增:媒体/文件相关字段
media_url: Optional[str] = None
file_name: Optional[str] = None
file_size: Optional[int] = None
extra_data: Optional[Dict[str, Any]] = None
# M1 新增:引用回复
reply_to_id: Optional[str] = None
ai_suggestion: bool
is_read: bool
created_at: datetime
# M2 新增:消息状态和可撤回时间
status: str = "sent"
recallable_until: Optional[datetime] = None
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 消息列表响应 Schema
# --------------------------------------------------------------------------
class MessageListResponse(BaseModel):
"""消息列表响应 Schema。
包含消息列表和是否还有更多消息的标志,
用于向上加载历史消息。
Attributes:
items: 消息列表
has_more: 是否还有更多历史消息
"""
items: List[MessageResponse]
has_more: bool
+106
View File
@@ -0,0 +1,106 @@
# =============================================================================
# 企微IT智能服务台 — 快速回复模板 Pydantic Schema
# =============================================================================
# 说明:定义快速回复模板的请求/响应数据结构
# 支持 CRUD 操作:创建、读取、更新、删除
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# --------------------------------------------------------------------------
# 创建快速回复模板 Schema
# --------------------------------------------------------------------------
class QuickReplyCreate(BaseModel):
"""创建快速回复模板请求 Schema。
Attributes:
category: 分类(账号/网络/软件/硬件/通用)
title: 模板标题
content: 模板内容(支持 {employee_name} 等变量)
variables: 可用变量列表
sort_order: 排序权重
"""
category: str = Field(default="通用", max_length=64, description="分类")
title: str = Field(..., min_length=1, max_length=128, description="模板标题")
content: str = Field(..., min_length=1, description="模板内容")
variables: List[str] = Field(default_factory=list, description="可用变量列表")
sort_order: int = Field(default=0, description="排序权重")
# --------------------------------------------------------------------------
# 更新快速回复模板 Schema
# --------------------------------------------------------------------------
class QuickReplyUpdate(BaseModel):
"""更新快速回复模板请求 Schema。
所有字段可选,只更新传入的字段。
Attributes:
category: 分类
title: 模板标题
content: 模板内容
variables: 可用变量列表
sort_order: 排序权重
"""
category: Optional[str] = Field(None, max_length=64, description="分类")
title: Optional[str] = Field(None, max_length=128, description="模板标题")
content: Optional[str] = Field(None, description="模板内容")
variables: Optional[List[str]] = Field(None, description="可用变量列表")
sort_order: Optional[int] = Field(None, description="排序权重")
# --------------------------------------------------------------------------
# 快速回复模板响应 Schema
# --------------------------------------------------------------------------
class QuickReplyResponse(BaseModel):
"""快速回复模板响应 Schema。
返回给前端的快速回复模板数据结构。
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换。
Attributes:
id: 模板ID
category: 分类
title: 模板标题
content: 模板内容
variables: 可用变量列表
sort_order: 排序权重
status: 状态(draft/pending_review/approved/rejected
version: 版本号
submitted_by: 提交人 agent_id
created_at: 创建时间
updated_at: 更新时间
"""
id: str
category: str
title: str
content: str
variables: List[str]
sort_order: int
status: str = "approved"
version: int = 1
submitted_by: Optional[str] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 快速回复模板列表响应 Schema
# --------------------------------------------------------------------------
class QuickReplyListResponse(BaseModel):
"""快速回复模板列表响应 Schema。
Attributes:
items: 模板列表
"""
items: List[QuickReplyResponse]
+239
View File
@@ -0,0 +1,239 @@
# =============================================================================
# 角色 Pydantic Schema
# =============================================================================
# 说明:定义角色相关的请求/响应数据结构
# 包含:角色响应、角色分配、角色映射规则等 Schema
# =============================================================================
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 角色响应 Schema
# --------------------------------------------------------------------------
class RoleResponse(BaseModel):
"""角色响应 Schema。
Attributes:
id: 角色ID
name: 角色标识(user/agent/admin
display_name: 显示名称(用户/坐席/管理员)
description: 角色描述
permissions: 权限列表
is_default: 是否默认角色
user_count: 拥有该角色的用户数(可选)
created_at: 创建时间
updated_at: 更新时间
"""
id: str
name: str
display_name: str
description: Optional[str] = None
permissions: List[str] = []
is_default: bool = False
user_count: Optional[int] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 用户角色响应 Schema
# --------------------------------------------------------------------------
class UserRoleResponse(BaseModel):
"""用户角色响应 Schema。
Attributes:
id: 记录ID
employee_id: 企微 UserID
role_id: 角色 ID
role_name: 角色标识
role_display_name: 角色显示名称
source: 角色来源(auto/tag/ehr/manual
assigned_by: 分配者
assigned_at: 分配时间
expires_at: 过期时间
"""
id: str
employee_id: str
role_id: str
role_name: str
role_display_name: str
source: str
assigned_by: Optional[str] = None
assigned_at: datetime
expires_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 角色分配请求 Schema
# --------------------------------------------------------------------------
class RoleAssignRequest(BaseModel):
"""角色分配请求 Schema。
Attributes:
employee_id: 企微 UserID
role_name: 角色标识(user/agent/admin
reason: 分配原因(可选)
"""
employee_id: str = Field(..., min_length=1, max_length=100, description="企微 UserID")
role_name: str = Field(..., min_length=1, max_length=50, description="角色标识")
reason: Optional[str] = Field(None, max_length=500, description="分配原因")
# --------------------------------------------------------------------------
# 角色撤销请求 Schema
# --------------------------------------------------------------------------
class RoleRevokeRequest(BaseModel):
"""角色撤销请求 Schema。
Attributes:
employee_id: 企微 UserID
role_name: 角色标识(user/agent/admin
reason: 撤销原因(可选)
"""
employee_id: str = Field(..., min_length=1, max_length=100, description="企微 UserID")
role_name: str = Field(..., min_length=1, max_length=50, description="角色标识")
reason: Optional[str] = Field(None, max_length=500, description="撤销原因")
# --------------------------------------------------------------------------
# 角色映射规则响应 Schema
# --------------------------------------------------------------------------
class RoleMappingRuleResponse(BaseModel):
"""角色映射规则响应 Schema。
Attributes:
id: 规则ID
role_id: 目标角色 ID
role_name: 目标角色标识
source_type: 来源类型(wecom_tag/ehr_position
source_value: 来源值(标签名/岗位关键词)
priority: 优先级
is_active: 是否启用
created_at: 创建时间
"""
id: str
role_id: str
role_name: str
source_type: str
source_value: str
priority: int = 0
is_active: bool = True
created_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 角色映射规则创建/更新请求 Schema
# --------------------------------------------------------------------------
class RoleMappingRuleRequest(BaseModel):
"""角色映射规则创建/更新请求 Schema。
Attributes:
role_name: 目标角色标识(user/agent/admin
source_type: 来源类型(wecom_tag/ehr_position
source_value: 来源值(标签名/岗位关键词)
priority: 优先级(数值越大优先级越高)
is_active: 是否启用
"""
role_name: str = Field(..., min_length=1, max_length=50, description="目标角色标识")
source_type: str = Field(..., min_length=1, max_length=50, description="来源类型")
source_value: str = Field(..., min_length=1, max_length=200, description="来源值")
priority: int = Field(0, ge=0, le=100, description="优先级(0-100")
is_active: bool = Field(True, description="是否启用")
@field_validator("source_type")
@classmethod
def validate_source_type(cls, v: str) -> str:
"""校验来源类型是否合法。"""
allowed_types = {"wecom_tag", "ehr_position"}
if v not in allowed_types:
raise ValueError(f"无效的来源类型: {v},合法值为: {allowed_types}")
return v
@field_validator("role_name")
@classmethod
def validate_role_name(cls, v: str) -> str:
"""校验角色标识是否合法。"""
allowed_roles = {"user", "agent", "admin"}
if v not in allowed_roles:
raise ValueError(f"无效的角色标识: {v},合法值为: {allowed_roles}")
return v
@field_validator("source_value")
@classmethod
def validate_source_value(cls, v: str) -> str:
"""校验来源值是否包含恶意内容。"""
# 过滤特殊字符
forbidden_chars = {"<", ">", ";", "'", '"', "\\", "/", "(", ")"}
for char in v:
if char in forbidden_chars:
raise ValueError(f"来源值包含非法字符: {char}")
return v
# --------------------------------------------------------------------------
# Portal 用户信息响应 Schema
# --------------------------------------------------------------------------
class PortalUserInfo(BaseModel):
"""Portal 用户信息响应 Schema。
用于路由选择页展示用户信息和角色列表。
Attributes:
employee_id: 企微 UserID
name: 姓名
department: 部门
avatar: 头像URL
roles: 角色列表
current_role: 当前选择的角色
"""
employee_id: str
name: str
department: Optional[str] = None
avatar: Optional[str] = None
roles: List[RoleResponse] = []
current_role: str = "user"
# --------------------------------------------------------------------------
# 角色切换请求 Schema
# --------------------------------------------------------------------------
class SwitchRoleRequest(BaseModel):
"""角色切换请求 Schema。
Attributes:
new_role: 目标角色标识
"""
new_role: str = Field(..., min_length=1, max_length=50, description="目标角色标识")
# --------------------------------------------------------------------------
# 角色切换响应 Schema
# --------------------------------------------------------------------------
class SwitchRoleResponse(BaseModel):
"""角色切换响应 Schema。
Attributes:
current_role: 切换后的角色标识
redirect_url: 重定向URL
"""
current_role: str
redirect_url: str
+158
View File
@@ -0,0 +1,158 @@
# =============================================================================
# 企微IT智能服务台 — 待办事项 Pydantic Schema
# =============================================================================
# 说明:定义待办事项的 CRUD 数据结构
# 包含:创建、更新、响应 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 待办类型和优先级的合法值
# --------------------------------------------------------------------------
VALID_TODO_TYPES = {"ticket", "approval", "device"}
VALID_TODO_PRIORITIES = {"urgent", "high", "normal"}
VALID_TODO_STATUSES = {"pending", "processing", "resolved"}
# --------------------------------------------------------------------------
# 创建待办事项 Schema
# --------------------------------------------------------------------------
class TodoItemCreate(BaseModel):
"""创建待办事项请求 Schema。
Attributes:
type: 待办类型(ticket/approval/device
title: 待办标题
priority: 优先级(urgent/high/normal
description: 详细描述(JSON
assigned_agent_id: 分配的坐席ID(可选)
corp_id: 企业微信企业ID
"""
type: str = Field(default="ticket", description="待办类型: ticket/approval/device")
title: str = Field(..., min_length=1, max_length=256, description="待办标题")
priority: str = Field(default="normal", description="优先级: urgent/high/normal")
description: Dict[str, Any] = Field(default_factory=dict, description="详细描述")
assigned_agent_id: Optional[str] = Field(None, max_length=64, description="分配的坐席ID")
corp_id: str = Field(default="", max_length=64, description="企业微信企业ID")
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
"""校验待办类型是否合法。"""
if v not in VALID_TODO_TYPES:
raise ValueError(f"无效的待办类型: {v},合法值为: {VALID_TODO_TYPES}")
return v
@field_validator("priority")
@classmethod
def validate_priority(cls, v: str) -> str:
"""校验优先级是否合法。"""
if v not in VALID_TODO_PRIORITIES:
raise ValueError(f"无效的优先级: {v},合法值为: {VALID_TODO_PRIORITIES}")
return v
# --------------------------------------------------------------------------
# 更新待办事项 Schema
# --------------------------------------------------------------------------
class TodoItemUpdate(BaseModel):
"""更新待办事项请求 Schema。
所有字段可选,只更新传入的字段。
Attributes:
type: 待办类型
title: 待办标题
priority: 优先级
description: 详细描述
status: 状态
assigned_agent_id: 分配的坐席ID
"""
type: Optional[str] = Field(None, description="待办类型: ticket/approval/device")
title: Optional[str] = Field(None, max_length=256, description="待办标题")
priority: Optional[str] = Field(None, description="优先级: urgent/high/normal")
description: Optional[Dict[str, Any]] = Field(None, description="详细描述")
status: Optional[str] = Field(None, description="状态: pending/processing/resolved")
assigned_agent_id: Optional[str] = Field(None, max_length=64, description="分配的坐席ID")
@field_validator("type")
@classmethod
def validate_type(cls, v: Optional[str]) -> Optional[str]:
"""校验待办类型是否合法。"""
if v is not None and v not in VALID_TODO_TYPES:
raise ValueError(f"无效的待办类型: {v},合法值为: {VALID_TODO_TYPES}")
return v
@field_validator("priority")
@classmethod
def validate_priority(cls, v: Optional[str]) -> Optional[str]:
"""校验优先级是否合法。"""
if v is not None and v not in VALID_TODO_PRIORITIES:
raise ValueError(f"无效的优先级: {v},合法值为: {VALID_TODO_PRIORITIES}")
return v
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""校验状态是否合法。"""
if v is not None and v not in VALID_TODO_STATUSES:
raise ValueError(f"无效的状态: {v},合法值为: {VALID_TODO_STATUSES}")
return v
# --------------------------------------------------------------------------
# 待办事项响应 Schema
# --------------------------------------------------------------------------
class TodoItemResponse(BaseModel):
"""待办事项响应 Schema。
返回给前端的待办事项数据结构。
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换。
Attributes:
id: 待办唯一标识
type: 待办类型
title: 待办标题
priority: 优先级
description: 详细描述
status: 状态
assigned_agent_id: 分配的坐席ID
corp_id: 企业微信企业ID
created_at: 创建时间
updated_at: 更新时间
"""
id: str
type: str
title: str
priority: str
description: Dict[str, Any]
status: str
assigned_agent_id: Optional[str] = None
corp_id: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 待办事项列表响应 Schema
# --------------------------------------------------------------------------
class TodoItemListResponse(BaseModel):
"""待办事项列表响应 Schema。
Attributes:
items: 待办事项列表
total: 总数
"""
items: List[TodoItemResponse]
total: int
@@ -0,0 +1,128 @@
# =============================================================================
# 企微IT智能服务台 — 排障模板 Pydantic Schema
# =============================================================================
# 说明:定义排障模板的 CRUD 数据结构
# 包含:创建、更新、响应 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 排障模板分类合法值
# --------------------------------------------------------------------------
VALID_TEMPLATE_CATEGORIES = {"vpn", "email", "system", "account"}
# --------------------------------------------------------------------------
# 创建排障模板 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateCreate(BaseModel):
"""创建排障模板请求 Schema。
Attributes:
name: 模板名称
category: 分类(vpn/email/system/account
path_steps: 排障步骤路径
flowchart: 流程图定义
is_active: 是否启用
"""
name: str = Field(..., min_length=1, max_length=256, description="模板名称")
category: str = Field(default="system", description="分类: vpn/email/system/account")
path_steps: List[Dict[str, Any]] = Field(
default_factory=list, description="排障步骤路径"
)
flowchart: Dict[str, Any] = Field(
default_factory=dict, description="流程图定义"
)
is_active: bool = Field(default=True, description="是否启用")
@field_validator("category")
@classmethod
def validate_category(cls, v: str) -> str:
"""校验分类是否合法。"""
if v not in VALID_TEMPLATE_CATEGORIES:
raise ValueError(f"无效的分类: {v},合法值为: {VALID_TEMPLATE_CATEGORIES}")
return v
# --------------------------------------------------------------------------
# 更新排障模板 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateUpdate(BaseModel):
"""更新排障模板请求 Schema。
所有字段可选,只更新传入的字段。
Attributes:
name: 模板名称
category: 分类
path_steps: 排障步骤路径
flowchart: 流程图定义
is_active: 是否启用
"""
name: Optional[str] = Field(None, max_length=256, description="模板名称")
category: Optional[str] = Field(None, description="分类: vpn/email/system/account")
path_steps: Optional[List[Dict[str, Any]]] = Field(None, description="排障步骤路径")
flowchart: Optional[Dict[str, Any]] = Field(None, description="流程图定义")
is_active: Optional[bool] = Field(None, description="是否启用")
@field_validator("category")
@classmethod
def validate_category(cls, v: Optional[str]) -> Optional[str]:
"""校验分类是否合法。"""
if v is not None and v not in VALID_TEMPLATE_CATEGORIES:
raise ValueError(f"无效的分类: {v},合法值为: {VALID_TEMPLATE_CATEGORIES}")
return v
# --------------------------------------------------------------------------
# 排障模板响应 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateResponse(BaseModel):
"""排障模板响应 Schema。
返回给前端的排障模板数据结构。
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换。
Attributes:
id: 模板唯一标识
name: 模板名称
category: 分类
path_steps: 排障步骤路径
flowchart: 流程图定义
is_active: 是否启用
created_at: 创建时间
updated_at: 更新时间
"""
id: str
name: str
category: str
path_steps: List[Dict[str, Any]] = Field(default_factory=list, description="排障步骤路径")
flowchart: Dict[str, Any] = Field(default_factory=dict, description="流程图定义")
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 排障模板列表响应 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateListResponse(BaseModel):
"""排障模板列表响应 Schema。
Attributes:
items: 排障模板列表
total: 总数
"""
items: List[TroubleshootingTemplateResponse]
total: int
+79
View File
@@ -0,0 +1,79 @@
# =============================================================================
# 企微IT智能服务台 — 企微回调消息 Pydantic Schema
# =============================================================================
# 说明:定义企微回调消息的数据结构
# 包含:GET验证请求、POST消息体、加解密相关
# =============================================================================
from typing import Optional
from pydantic import BaseModel, Field
# --------------------------------------------------------------------------
# 企微回调验证请求 Schema(GET 请求)
# --------------------------------------------------------------------------
class WecomCallbackVerify(BaseModel):
"""企微回调URL验证请求 Schema。
企微管理后台配置回调URL时,会发送GET请求验证。
需要验证签名并返回解密后的 echostr。
Attributes:
msg_signature: 企微签名(用于验证请求来源)
timestamp: 时间戳
nonce: 随机数
echostr: 加密的验证字符串(解密后返回给企微)
"""
msg_signature: str = Field(..., description="企微签名")
timestamp: str = Field(..., description="时间戳")
nonce: str = Field(..., description="随机数")
echostr: str = Field(..., description="加密的验证字符串")
# --------------------------------------------------------------------------
# 企微回调消息体 Schema(POST 请求解析后)
# --------------------------------------------------------------------------
class WecomCallbackMessage(BaseModel):
"""企微回调消息体 Schema。
企微推送消息时发送的XML解析后的结构。
包含加密的消息内容。
Attributes:
to_user_name: 接收方(企业ID
agent_id: 应用AgentID
encrypt: 加密的消息内容
"""
to_user_name: str = Field(default="", description="接收方企业ID")
agent_id: str = Field(default="", description="应用AgentID")
encrypt: str = Field(..., description="加密的消息内容")
# --------------------------------------------------------------------------
# 企微消息内容 Schema(解密后的消息)
# --------------------------------------------------------------------------
class WecomMessageContent(BaseModel):
"""企微消息内容 Schema(解密后)。
AES解密后的XML消息解析结果。
Attributes:
to_user_name: 接收方
from_user_name: 发送者企微UserID
create_time: 消息创建时间戳
msg_type: 消息类型(text/image等)
content: 消息内容
msg_id: 消息ID
agent_id: 应用AgentID
"""
to_user_name: str = Field(default="", description="接收方")
from_user_name: str = Field(..., description="发送者企微UserID")
create_time: int = Field(default=0, description="消息创建时间戳")
msg_type: str = Field(default="text", description="消息类型")
content: str = Field(default="", description="消息内容")
msg_id: str = Field(default="", description="消息ID")
agent_id: str = Field(default="", description="应用AgentID")
+22
View File
@@ -0,0 +1,22 @@
# =============================================================================
# 企微IT智能服务台 — 服务包初始化
# =============================================================================
# 说明:将 services/ 目录标记为 Python 包
# 导出所有服务类,方便统一导入
# =============================================================================
from app.services.wecom_service import WecomService
from app.services.message_router import MessageRouter
from app.services.scoring_service import ScoringService
from app.services.session_service import SessionService
from app.services.funny_phrase_service import FunnyPhraseService
from app.services.ai_handler import AIHandler
__all__ = [
"WecomService",
"MessageRouter",
"ScoringService",
"SessionService",
"FunnyPhraseService",
"AIHandler",
]
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
# =============================================================================
# 企微IT智能服务台 — 统一 AI 回复处理器
# =============================================================================
# 说明:统一封装 AI 调用逻辑,供 H5 端和企微回调端共用,确保两端行为一致。
# 1. 打招呼检测 → 引导用户描述问题(不计数)
# 2. 呼叫人工拦截 → 引导先描述问题(不计数)
# 3. AI 调用 → 命中则回复并计数,未命中则转人工
# 4. AI 异常降级 → 模板回复(不计数,不转人工)
#
# 为什么需要此模块:
# - 原 h5.py 和 MessageRouter._try_ai_reply() 各自独立实现 AI 调用逻辑
# - 两端行为不一致:打招呼/呼叫人工拦截只在 H5 端有,计数规则不同
# - 统一后确保无论从哪个入口进来,用户获得的 AI 体验完全一致
# =============================================================================
import logging
import random
from dataclasses import dataclass
from typing import Optional
from app.services.ai_service import AIService
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# 打招呼关键词(匹配后 AI 引导用户描述问题,不计数)
# --------------------------------------------------------------------------
_GREETING_KEYWORDS = [
"你好", "您好", "hi", "hello", "", "在吗", "在不在",
"哈喽", "", "早上好", "下午好", "晚上好",
]
# --------------------------------------------------------------------------
# 直接呼叫人工关键词(匹配后 AI 引导用户先描述问题,不计数)
# --------------------------------------------------------------------------
_CALL_HUMAN_KEYWORDS = [
"人工", "人工坐席", "转人工", "客服", "我要人工",
"找人工", "人工客服", "转接人工", "人工服务",
"找客服", "联系人工", "我要找人", "不要机器人",
"真人", "摇人", "找人",
]
# AI 引导话术(打招呼)
_GREETING_GUIDE = (
"你好!我是 IT 智能助手 🤖\n"
"请直接描述你遇到的 IT 问题,比如:\n"
"• 打印机连不上\n"
"• 电脑蓝屏了\n"
"• VPN 无法登录\n"
"我先帮你分析,搞不定再帮你转人工坐席~"
)
# AI 引导话术(呼叫人工)
_CALL_HUMAN_GUIDE = (
"别急~先告诉我你遇到了什么问题?\n"
"我先帮你排查一下,大部分问题我都能解决 💪\n"
"如果确实需要人工坐席,我会帮你转接的!"
)
# AI 未命中转人工话术
_AI_MISS_GUIDE = (
"🤖 AI 暂未学习到相关知识,正在为您转接 IT 坐席,请稍候..."
)
# --------------------------------------------------------------------------
# 非文本消息自动回复模板(图片引导,其余类型暂不支持)
# --------------------------------------------------------------------------
_IMAGE_REPLY = (
"收到您的截图 📷\n"
"请补充文字描述您遇到的问题,以便更快为您处理。\n"
"例如:\n"
"• 这是什么软件的报错截图?\n"
"• 您在操作什么时出现的?\n"
"• 错误信息的具体内容是什么?"
)
_NON_TEXT_REPLY_TEMPLATE = (
"暂不支持{type_name}消息 😅\n"
"请用文字描述您的问题,我会尽快为您处理。"
)
# AI 调用失败降级模板(使用 {topic} 占位符,运行时替换为用户消息摘要)
_FALLBACK_TEMPLATES = [
"收到!关于「{topic}」的问题,让我来帮你分析一下…\n\n"
"这类问题通常由以下原因导致:\n"
"1. 网络连接异常\n"
"2. 设备驱动问题\n"
"3. 系统配置错误\n\n"
"你可以先尝试重启设备,如果问题依旧,请告诉我具体的错误提示。",
"明白,关于「{topic}」的问题…\n\n"
"建议按以下步骤排查:\n"
"1. 检查网络是否正常\n"
"2. 确认相关服务是否启动\n"
"3. 查看是否有报错提示\n\n"
"如果以上步骤无法解决,请补充更多细节。",
]
@dataclass
class AIReplyResult:
"""AI 回复结果(统一返回结构)。
无论消息经过哪种处理路径(打招呼/呼叫人工/AI命中/AI未命中/降级),
都返回此结构,由调用方决定如何持久化和发送。
Attributes:
content: 回复内容
reply_type: 回复类型
- "greeting": 打招呼引导
- "call_human": 呼叫人工拦截引导
- "ai_hit": AI 命中知识库
- "ai_miss": AI 未命中,需转人工
- "ai_fallback": AI 调用异常,降级模板回复
is_guidance: 是否为引导类消息(打招呼或呼叫人工),前端据此决定 UI 展示
should_count: 是否应增加 ai_substantive_reply_count(仅 AI 命中时为 True
should_transfer: 是否应转人工(状态改为 queued)
dify_conversation_id: Dify 会话ID(用于多轮对话上下文,AI 命中/未命中时更新)
"""
content: str
reply_type: str
is_guidance: bool = False
should_count: bool = False
should_transfer: bool = False
dify_conversation_id: Optional[str] = None
class AIHandler:
"""统一 AI 回复处理器。
封装打招呼检测、呼叫人工拦截、AI 调用、命中判断、计数规则、
转人工逻辑,供 H5 端和企微回调端(MessageRouter)共用,
确保两端行为完全一致。
处理流程(按优先级):
1. 检测打招呼 → 返回引导话术(不计数,不转人工)
2. 检测呼叫人工 → 返回拦截引导(不计数,不转人工)
3. 调用 Dify API
- 命中 → 返回 AI 回复(计数+1,不转人工)
- 未命中 → 返回转人工提示(不计数,转人工)
4. AI 调用异常 → 返回降级模板回复(不计数,不转人工)
计数规则(统一):
- 仅 AI 命中知识库时 ai_substantive_reply_count +1
- 打招呼/呼叫人工/未命中/降级 均不计数
"""
def __init__(self, ai_service: AIService):
"""初始化 AI 处理器。
Args:
ai_service: AI 服务实例(Dify API 封装),通常为应用级共享单例
"""
self.ai_service = ai_service
def is_greeting(self, content: str) -> bool:
"""检测是否为打招呼消息。
匹配规则:用户消息(小写化、去前后空格后)包含任意打招呼关键词。
Args:
content: 用户消息内容
Returns:
bool: 是否为打招呼
"""
text = content.strip().lower()
return any(kw in text for kw in _GREETING_KEYWORDS)
def is_call_human(self, content: str) -> bool:
"""检测是否为直接呼叫人工。
匹配规则:用户消息(小写化、去前后空格后)包含任意呼叫人工关键词。
拦截后引导用户先描述问题,避免直接转人工浪费坐席资源。
Args:
content: 用户消息内容
Returns:
bool: 是否为呼叫人工
"""
text = content.strip().lower()
return any(kw in text for kw in _CALL_HUMAN_KEYWORDS)
async def handle_message(
self,
content: str,
dify_conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> AIReplyResult:
"""处理用户消息,返回统一的 AI 回复结果。
按照优先级依次检测:打招呼 → 呼叫人工 → AI 调用。
每种路径返回不同的 reply_type,由调用方根据结果更新会话状态和计数。
Args:
content: 用户消息内容
dify_conversation_id: Dify 会话ID(用于多轮对话上下文)
user_id: 用户标识(用于 Dify 日志追溯)
Returns:
AIReplyResult: 统一的 AI 回复结果
"""
# ==================================================================
# 1. 检测打招呼 → 引导描述问题,不计数,不转人工
# ==================================================================
if self.is_greeting(content):
logger.info(f"打招呼引导: user_id={user_id}")
return AIReplyResult(
content=_GREETING_GUIDE,
reply_type="greeting",
is_guidance=True,
should_count=False,
should_transfer=False,
dify_conversation_id=dify_conversation_id,
)
# ==================================================================
# 2. 检测呼叫人工 → 拦截引导,不计数,不转人工
# ==================================================================
if self.is_call_human(content):
logger.info(f"人工拦截引导: user_id={user_id}")
return AIReplyResult(
content=_CALL_HUMAN_GUIDE,
reply_type="call_human",
is_guidance=True,
should_count=False,
should_transfer=False,
dify_conversation_id=dify_conversation_id,
)
# ==================================================================
# 3. 调用 Dify API 获取 AI 回复
# ==================================================================
try:
ai_result = await self.ai_service.get_reply(
message=content,
conversation_id=dify_conversation_id,
user_id=user_id,
)
# 提取 Dify 返回的 conversation_id(用于多轮对话上下文)
new_conv_id = ai_result.get("conversation_id") or dify_conversation_id
if ai_result["hit"]:
# AI 命中:使用 Dify 回复,计数+1
logger.info(
f"AI命中: user_id={user_id}, "
f"content_length={len(ai_result['content'])}"
)
return AIReplyResult(
content=ai_result["content"],
reply_type="ai_hit",
is_guidance=False,
should_count=True,
should_transfer=False,
dify_conversation_id=new_conv_id,
)
else:
# AI 未命中:转人工
logger.info(f"AI未命中转人工: user_id={user_id}")
return AIReplyResult(
content=_AI_MISS_GUIDE,
reply_type="ai_miss",
is_guidance=False,
should_count=False,
should_transfer=True,
dify_conversation_id=new_conv_id,
)
except Exception as e:
# ==============================================================
# 4. AI 调用异常:降级模板回复
# - 不计数(修复原 h5.py 降级误计数的 Bug)
# - 不转人工(降级是临时故障,用户可继续尝试)
# ==============================================================
logger.error(f"AI调用失败(降级模板回复): {e}")
topic = content.strip()[:15]
fallback_content = random.choice(_FALLBACK_TEMPLATES).format(topic=topic)
return AIReplyResult(
content=fallback_content,
reply_type="ai_fallback",
is_guidance=False,
should_count=False,
should_transfer=False,
dify_conversation_id=dify_conversation_id,
)
+271
View File
@@ -0,0 +1,271 @@
# =============================================================================
# 企微IT智能服务台 — AI 服务(Dify 接入)
# =============================================================================
# 做什么:封装 Dify API 调用,实现 AI 自动回复
# 为什么:
# - ARCHITECTURE.md 设计了 ai_handling 状态,但当前未实现
# - 现有系统交接文档提供了 Dify API 地址和 Key
# - 这是实现「AI 自助解决」的核心模块
# 依赖:需要 Dify API 可达(生产环境 http://yw-dify.dc.servyou-it.com
# =============================================================================
import json
import logging
import asyncio
from typing import Any, Dict, List, Optional, AsyncGenerator
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class AIService:
"""AI 服务:封装 Dify API,提供 AI 回复能力。
支持两种调用模式:
1. 非流式(简单场景):一次性获取完整回复
2. 流式(推荐):SSE 流式返回,前端可逐字显示
参考:现有系统交接文档
- API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
- Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat
"""
def __init__(self):
"""初始化 AI 服务。
做什么:从配置读取 Dify API 地址和认证信息
为什么:集中管理 API 配置,便于切换测试/生产环境
"""
# Dify 兼容 OpenAI 格式的 API 端点
self.api_url = settings.dify_api_url
# Dify API Key(格式:base_url|app_id|app_name
self.api_key = settings.dify_api_key
# 请求超时(秒)
self.timeout = settings.dify_timeout
# httpx 异步客户端(复用连接池)
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 httpx 异步客户端。
做什么:懒加载 httpx.AsyncClient,复用连接池
为什么:避免每次请求都创建新连接,提升性能
"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
)
return self._client
async def close(self):
"""关闭 httpx 客户端。
做什么:释放连接池资源
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
logger.debug("AIService httpx client closed")
# --------------------------------------------------------------------------
# 非流式调用:一次性获取 AI 完整回复
# --------------------------------------------------------------------------
async def get_reply(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""调用 Dify API 获取 AI 回复(非流式)。
Args:
message: 员工发送的消息内容
conversation_id: 会话ID(用于 Dify 多轮对话上下文)
user_id: 员工企微 UserID(用于 Dify 用户标识)
Returns:
Dict: {
"content": str, # AI 回复内容
"hit": bool, # 是否命中知识库(可回复)
"conversation_id": str, # Dify 会话ID(用于后续多轮对话)
"usage": dict, # Token 用量(可选)
}
做什么:发送消息到 Dify,解析返回内容,判断是否能回复
为什么:
- 非流式适合简单场景,代码简单
- 返回结构兼容 OpenAI Chat Completions 格式
- 通过回复内容判断是否命中知识库(有实质内容 = 命中)
"""
payload = {
"model": "Chat", # Dify 应用名称(来自 API Key 格式)
"messages": [
{"role": "user", "content": message}
],
"stream": False, # 非流式
"temperature": 0.1, # 低温度,保证回答稳定性
}
# 传入 Dify 会话ID,保持多轮对话上下文
if conversation_id:
payload["conversation_id"] = conversation_id
# 传入用户标识(Dify 侧用于日志和追溯)
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
logger.info(f"调用 Dify API: message={message[:50]}...")
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
data = response.json()
# 解析 OpenAI 兼容格式的返回
# 格式:{"choices": [{"message": {"content": "..."}}]}
choices = data.get("choices", [])
if not choices:
logger.warning("Dify API 返回空 choices")
return {
"content": "",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
reply_content = choices[0]["message"]["content"]
# 判断是否命中知识库:
# 策略1:检查内容是否为空或过长(Dify 可能返回提示语)
# 策略2:检查是否包含「抱歉」「不知道」等无法回答的特征词
hit = self._check_knowledge_hit(reply_content)
# 提取 Dify 返回的 conversation_id(用于多轮对话)
dify_conv_id = data.get("conversation_id", conversation_id or "")
logger.info(
f"Dify API 返回: hit={hit}, "
f"content_length={len(reply_content)}, "
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
)
return {
"content": reply_content,
"hit": hit,
"conversation_id": dify_conv_id,
"usage": data.get("usage", {}),
}
except httpx.TimeoutException:
logger.error("Dify API 超时")
return {
"content": "⏰ AI 服务响应超时,请稍后再试或输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
except httpx.HTTPStatusError as e:
logger.error(f"Dify API HTTP 错误: status={e.response.status_code}")
return {
"content": "⚠️ AI 服务暂时不可用,请输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
except Exception as e:
logger.error(f"Dify API 调用失败: {e}")
return {
"content": "⚠️ AI 服务异常,请输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
# --------------------------------------------------------------------------
# 流式调用:SSE 流式返回(供 WebSocket 推送给前端)
# --------------------------------------------------------------------------
async def get_reply_stream(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""调用 Dify API 获取流式 AI 回复(SSE)。
Args:
message: 员工发送的消息内容
conversation_id: Dify 会话ID
user_id: 员工企微 UserID
Yields:
Dict: {
"delta": str, # 增量内容
"finished": bool, # 是否结束
"conversation_id": str,
"hit": bool, # 最终判断是否命中
}
做什么:SSE 流式读取 Dify 返回,逐块 yield 给调用方
为什么:
- 流式返回能提升用户体验(不用等 AI 全部生成完才显示)
- 通过 WebSocket 推送增量内容到 H5 前端
- 目前第一步先实现非流式,流式作为后续优化
"""
# TODO: 第一步简化,先 yield 完整内容(非真正流式)
# 后续优化:解析 SSE 事件流,逐块 yield
result = await self.get_reply(message, conversation_id, user_id)
yield {
"delta": result["content"],
"finished": True,
"conversation_id": result["conversation_id"],
"hit": result["hit"],
}
# --------------------------------------------------------------------------
# 判断是否命中知识库
# --------------------------------------------------------------------------
def _check_knowledge_hit(self, content: str) -> bool:
"""判断 AI 回复是否命中知识库(可以回答用户问题)。
Args:
content: AI 回复内容
Returns:
bool: True=命中(可以回复),False=未命中(需转人工)
做什么:分析 AI 回复内容,判断是否能有效回答问题
为什么:
- Dify 在无法回答时通常会返回固定提示语
- 参考现有系统:「抱歉,您的问题可能不在服务业务范围内」
- 命中 = 有实质内容且不像是「无法回答」的提示
"""
if not content or len(content.strip()) < 5:
return False
# 未命中特征词(Dify 无法回答时的典型回复)
miss_keywords = [
"抱歉", "对不起", "不知道", "无法回答",
"不在服务范围内", "超出我的能力", "暂不支持",
"请转人工", "联系管理员",
]
content_lower = content.lower()
# 如果回复中包含多个未命中特征词 → 判断为未命中
miss_count = sum(1 for kw in miss_keywords if kw in content_lower)
if miss_count >= 2:
return False
# 如果回复长度过短(< 10 字符)且包含特征词 → 未命中
if len(content) < 10 and any(kw in content_lower for kw in miss_keywords):
return False
return True
+232
View File
@@ -0,0 +1,232 @@
# =============================================================================
# 企微IT智能服务台 — Redis 缓存服务
# =============================================================================
# 说明:封装 Redis 缓存操作,提供:
# 1. 消息去重(基于企微 MsgId)
# 2. 内容去重(基于用户 ID + 内容哈希,防快速重复发送)
# 3. 通用缓存读写(供其他服务复用)
#
# 去重窗口:
# - MsgId 去重:5 分钟(与企微重试窗口一致)
# - 内容去重:60 秒(防止用户快速重复发送相同消息)
# =============================================================================
import hashlib
import logging
from typing import Optional
import redis.asyncio as aioredis
logger = logging.getLogger(__name__)
# Redis key 前缀
MSG_DEDUP_PREFIX = "msg:dedup"
CONTENT_DEDUP_PREFIX = "msg:dedup:content"
# 默认 TTL(秒)
DEFAULT_MSG_DEDUP_TTL = 300 # 5 分钟,与企微重试窗口一致
DEFAULT_CONTENT_DEDUP_TTL = 60 # 60 秒,防止快速重复发送
class CacheService:
"""Redis 缓存服务,提供消息去重和通用缓存操作。
使用 Redis 的 SETNX + EXPIRE 语义实现幂等去重:
- 首次写入成功 → 返回 False(非重复)
- 再次写入失败 → 返回 True(重复)
降级策略:Redis 不可用时,去重检查自动放行(宁可重复处理,不可丢消息)。
Attributes:
redis: Redis 异步客户端(可为 None,降级时跳过去重)
"""
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
"""初始化缓存服务。
Args:
redis_client: Redis 异步客户端实例。
为 None 时去重检查自动放行(降级模式)。
"""
self.redis = redis_client
# --------------------------------------------------------------------------
# 消息去重(基于企微 MsgId
# --------------------------------------------------------------------------
async def is_duplicate(
self,
msg_id: str,
ttl: int = DEFAULT_MSG_DEDUP_TTL,
) -> bool:
"""基于企微 MsgId 判断消息是否重复。
利用 Redis SETNX 语义实现幂等检查:
- key 不存在 → 设置 key(带 TTL)→ 返回 False(非重复)
- key 已存在 → 跳过写入 → 返回 True(重复)
降级策略:Redis 不可用时返回 False(放行),记录警告日志。
Args:
msg_id: 企微消息唯一 IDMsgId 字段)
ttl: 去重窗口(秒),默认 300 秒(5 分钟)
Returns:
bool: True=重复消息(应跳过处理),False=首次收到(正常处理)
"""
if not msg_id:
logger.warning("msg_id 为空,跳过去重检查")
return False
if self.redis is None:
logger.debug("Redis 不可用,跳过 MsgId 去重检查(降级放行)")
return False
key = f"{MSG_DEDUP_PREFIX}:{msg_id}"
try:
# SETNX + EXPIRE 原子操作:key 不存在时设置并返回 True,已存在返回 False
is_new = await self.redis.set(key, "1", nx=True, ex=ttl)
if is_new:
logger.debug(f"MsgId 去重: 新消息 msg_id={msg_id}")
return False
else:
logger.info(f"MsgId 去重: 重复消息已过滤 msg_id={msg_id}")
return True
except Exception as e:
logger.warning(f"MsgId 去重检查异常(降级放行): msg_id={msg_id}, error={e}")
return False
# --------------------------------------------------------------------------
# 内容去重(基于用户 ID + 内容哈希)
# --------------------------------------------------------------------------
async def is_duplicate_content(
self,
user_id: str,
content: str,
ttl: int = DEFAULT_CONTENT_DEDUP_TTL,
) -> bool:
"""基于用户 ID + 内容哈希判断是否为快速重复发送。
场景:用户在短时间内连续发送相同内容的消息(如网络卡顿导致重复点击)。
与 MsgId 去重不同,这里处理的是不同 MsgId 但内容完全相同的消息。
使用 SHA256 对 user_id + content 生成哈希作为 Redis key
窗口默认 60 秒(防止快速重复发送,但不影响正常重新提问)。
降级策略:Redis 不可用时返回 False(放行),记录警告日志。
Args:
user_id: 发送者企微 UserID
content: 消息内容
ttl: 去重窗口(秒),默认 60 秒
Returns:
bool: True=重复内容(应跳过处理),False=首次收到(正常处理)
"""
if not user_id or not content:
logger.debug("user_id 或 content 为空,跳过内容去重检查")
return False
if self.redis is None:
logger.debug("Redis 不可用,跳过内容去重检查(降级放行)")
return False
# 使用 SHA256 生成内容哈希,避免 Redis key 中存储原始内容
content_hash = hashlib.sha256(f"{user_id}:{content}".encode("utf-8")).hexdigest()[:16]
key = f"{CONTENT_DEDUP_PREFIX}:{user_id}:{content_hash}"
try:
is_new = await self.redis.set(key, "1", nx=True, ex=ttl)
if is_new:
logger.debug(f"内容去重: 新消息 user_id={user_id}, hash={content_hash}")
return False
else:
logger.info(f"内容去重: 重复内容已过滤 user_id={user_id}, hash={content_hash}")
return True
except Exception as e:
logger.warning(
f"内容去重检查异常(降级放行): user_id={user_id}, hash={content_hash}, error={e}"
)
return False
# --------------------------------------------------------------------------
# 通用缓存操作
# --------------------------------------------------------------------------
async def get(self, key: str) -> Optional[str]:
"""从 Redis 获取缓存值。
Args:
key: 缓存 key
Returns:
Optional[str]: 缓存值,不存在或 Redis 不可用时返回 None
"""
if self.redis is None:
return None
try:
value = await self.redis.get(key)
return value.decode("utf-8") if isinstance(value, bytes) else value
except Exception as e:
logger.warning(f"Redis GET 异常: key={key}, error={e}")
return None
async def set(
self,
key: str,
value: str,
ttl: Optional[int] = None,
) -> bool:
"""向 Redis 写入缓存值。
Args:
key: 缓存 key
value: 缓存值
ttl: 过期时间(秒),为 None 时永不过期
Returns:
bool: True=写入成功,False=写入失败或 Redis 不可用
"""
if self.redis is None:
return False
try:
if ttl is not None:
await self.redis.setex(key, ttl, value)
else:
await self.redis.set(key, value)
return True
except Exception as e:
logger.warning(f"Redis SET 异常: key={key}, error={e}")
return False
async def delete(self, key: str) -> bool:
"""从 Redis 删除缓存 key。
Args:
key: 缓存 key
Returns:
bool: True=删除成功,False=删除失败或 Redis 不可用
"""
if self.redis is None:
return False
try:
await self.redis.delete(key)
return True
except Exception as e:
logger.warning(f"Redis DELETE 异常: key={key}, error={e}")
return False
# 默认实例:Redis 客户端在应用启动时通过 init_cache_service() 注入
# 为什么:ws.py 等模块需要导入一个 cache_service 实例来读取 Redis
cache_service = CacheService()
+33
View File
@@ -0,0 +1,33 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统集成模块
# =============================================================================
# 提供统一的适配层,让联软/火绒/aTrust/eHR用同一套接口规范接入。
# 上层业务只依赖 ExternalSystemService 统一门面,不直接调用任何Adapter。
#
# 使用方式:
# from app.services.external import ExternalSystemService, get_external_service
# svc = get_external_service()
# terminal = await svc.find_user_terminal("songxian")
# =============================================================================
from app.services.external.base import (
ExternalSystemAdapter,
TerminalInfo,
SecurityStatus,
VpnSession,
)
from app.services.external.config import ExternalSystemConfig
from app.services.external.cache import ExternalSystemCache
from app.services.external.mock import MockAdapter
from app.services.external.service import ExternalSystemService
__all__ = [
"ExternalSystemAdapter",
"TerminalInfo",
"SecurityStatus",
"VpnSession",
"ExternalSystemConfig",
"ExternalSystemCache",
"MockAdapter",
"ExternalSystemService",
]
+312
View File
@@ -0,0 +1,312 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统适配器抽象基类 + 统一数据模型
# =============================================================================
# 说明:
# 1. 定义所有外部系统共用的抽象接口(ABC)
# 2. 定义统一的DTO模型(TerminalInfo/SecurityStatus/VpnSession
# 3. 每个外部系统实现此接口,上层业务只依赖抽象接口
#
# 设计原则:
# - 默认返回None/空 — 子类按需覆写自己支持的方法
# - 不支持的能力不报错,返回None让调用方走降级逻辑
# - raw_data字段保留原始响应,调试用,生产环境可关闭
# =============================================================================
import logging
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# =============================================================================
# 统一数据模型(DTO
# =============================================================================
class TerminalInfo(BaseModel):
"""统一终端信息模型 — 所有Adapter返回同一结构
做什么:把联软/火绒/aTrust不同格式的终端数据映射到统一结构
为什么:上层业务代码不需要关心数据来自哪个系统
"""
# ── 来源标识 ──
source_system: str = Field(..., description="数据来源系统标识: lianruan/huorong/atrust/ehr")
# ── 基础标识 ──
terminal_id: Optional[str] = Field(None, description="终端在来源系统中的唯一ID")
computer_name: str = Field(..., description="计算机名")
# ── 网络信息 ──
ip_addresses: List[str] = Field(default_factory=list, description="IP地址列表(含VPN虚拟IP")
mac_addresses: List[str] = Field(default_factory=list, description="MAC地址列表")
# ── 系统信息 ──
os_version: Optional[str] = Field(None, description="操作系统版本")
is_online: bool = Field(False, description="是否在线")
# ── 用户映射(核心字段)──
logged_in_user: Optional[str] = Field(None, description="当前登录用户账号 — 映射核心字段")
logged_in_user_name: Optional[str] = Field(None, description="用户姓名")
department: Optional[str] = Field(None, description="所属部门")
# ── 硬件摘要 ──
hardware_summary: Optional[Dict] = Field(None, description="硬件摘要(CPU/内存/磁盘使用率等)")
# ── 时间信息 ──
last_seen: Optional[datetime] = Field(None, description="最后在线时间")
# ── 调试用 ──
raw_data: Optional[Dict] = Field(None, description="原始响应数据(调试用,生产可关闭)")
class VulnerabilityItem(BaseModel):
"""漏洞条目"""
name: str = Field(..., description="漏洞名称")
level: str = Field("info", description="严重程度: critical/high/medium/low/info")
description: Optional[str] = Field(None, description="漏洞描述")
publish_time: Optional[str] = Field(None, description="发布时间")
class SecurityStatus(BaseModel):
"""统一安全状态模型
做什么:聚合火绒的病毒/漏洞/隔离数据
为什么:坐席需要一目了然看到终端安全全貌
"""
source_system: str = Field(..., description="数据来源系统标识")
terminal_id: str = Field(..., description="终端ID")
computer_name: Optional[str] = Field(None, description="计算机名")
# ── 安全指标 ──
virus_total: int = Field(0, description="病毒事件总数")
virus_uncleaned: int = Field(0, description="未处理病毒数")
vulnerabilities: List[VulnerabilityItem] = Field(default_factory=list, description="高危漏洞列表")
high_vuln_count: int = Field(0, description="高危漏洞数量")
# ── 隔离状态 ──
is_isolated: bool = Field(False, description="是否被隔离")
isolation_source: Optional[str] = Field(None, description="隔离来源系统")
# ── 检查时间 ──
checked_at: datetime = Field(default_factory=datetime.now, description="检查时间")
class VpnSession(BaseModel):
"""VPN会话模型(仅aTrust
做什么:描述一个aTrust VPN在线会话
为什么:坐席需要知道远程员工是否通过VPN在线、VPN IP是什么
"""
source_system: str = "atrust"
session_id: Optional[str] = Field(None, description="会话ID(用于踢出操作)")
username: str = Field(..., description="用户名(登录名)")
display_name: Optional[str] = Field(None, description="显示名")
remote_ip: str = Field(..., description="接入IP(公网IP或'内网IP'")
vpn_ip: Optional[str] = Field(None, description="VPN虚拟内网IP — 火绒交叉匹配关键字段")
is_trusted: bool = Field(False, description="终端是否已授信")
os: Optional[str] = Field(None, description="接入终端操作系统")
last_login: Optional[datetime] = Field(None, description="最后登录时间")
domain: Optional[str] = Field(None, description="登录域")
# =============================================================================
# 适配器抽象基类
# =============================================================================
class ExternalSystemAdapter(ABC):
"""外部系统适配器抽象基类
做什么:定义所有外部系统共用的接口规范
为什么:让上层业务代码只依赖抽象接口,不感知底层系统差异
设计原则:
- 默认方法返回None/空列表/False,子类按需覆写自己支持的能力
- 不支持的能力不报错,让调用方走降级逻辑
- 每个Adapter只负责一个外部系统的对接
"""
@property
@abstractmethod
def system_name(self) -> str:
"""系统标识名称
返回值: 'lianruan' / 'huorong' / 'atrust' / 'ehr' / 'mock'
"""
...
@property
@abstractmethod
def is_available(self) -> bool:
"""当前系统是否可用(凭证已配置+网络可达)
做什么:检查配置是否完整,不实际发起网络请求
为什么:调用方可据此决定是否跳过本系统
"""
...
@abstractmethod
async def health_check(self) -> bool:
"""健康检查 — 验证凭证和网络连通性
做什么:实际发起一次轻量级API调用,确认系统可达
为什么:定期健康检查可提前发现连接问题
"""
...
# =========================================================================
# 终端查询能力
# =========================================================================
async def get_terminal_by_user(self, username: str) -> Optional[TerminalInfo]:
"""通过员工账号查询终端信息(映射核心方法)
做什么:输入员工账号,返回该员工使用的终端信息
为什么:这是员工→终端映射的核心入口
各系统实现方式:
- 联软:queryDevByParams(strusername=xxx) — 精确匹配
- 火绒:_list(ip=xxx) — 需配合联软IP交叉匹配
- aTrustqueryAll(bindUserList) — 终端绑定用户
- eHR:不提供终端数据,返回None
Args:
username: 员工账号(如 'songxian'
Returns:
TerminalInfo 或 None(系统不支持或未找到)
"""
return None
async def get_terminal_by_computer(self, computer_name: str) -> Optional[TerminalInfo]:
"""通过计算机名查询终端信息
Args:
computer_name: 计算机名(如 'IT-SONGXIAN'
"""
return None
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
"""查询终端详细信息(硬件/软件/网络配置)
做什么:返回比 get_terminal_by_user 更详细的信息
为什么:排查时需要硬件配置、磁盘使用率、已安装软件等
各系统实现方式:
- 联软:getDevAllInfo — 极详细(主板/CPU/内存/硬盘/网卡/显示器)
- 火绒:_info2 — 中等详细(硬件/软件/网络配置)
- aTrust/eHR:不支持
Args:
terminal_id: 终端在来源系统中的唯一ID
"""
return None
# =========================================================================
# 安全能力
# =========================================================================
async def get_security_status(self, terminal_id: str) -> Optional[SecurityStatus]:
"""获取终端安全状态(病毒/漏洞/隔离状态)
做什么:聚合安全指标,坐席一目了然
为什么:安全问题通常需要紧急处理
仅火绒支持此接口。
Args:
terminal_id: 终端ID(火绒的client_id
"""
return None
async def isolate_terminal(self, terminal_id: str, reason: str) -> bool:
"""隔离终端(断网)
做什么:调用火绒 _create(type=netctrl) 隔离终端
为什么:安全事件紧急处理,阻断威胁扩散
仅火绒支持。调用前必须二次确认+审计日志记录。
Args:
terminal_id: 终端ID
reason: 隔离原因(记入审计日志)
Returns:
True=成功, False=失败
Raises:
NotImplementedError: 本系统不支持隔离操作
"""
raise NotImplementedError(f"{self.system_name} 不支持终端隔离")
async def unisolate_terminal(self, terminal_id: str) -> bool:
"""解除终端隔离(恢复网络)
仅火绒支持。
Args:
terminal_id: 终端ID
Returns:
True=成功, False=失败
"""
raise NotImplementedError(f"{self.system_name} 不支持解除隔离")
# =========================================================================
# VPN/在线状态能力
# =========================================================================
async def get_vpn_sessions(self, username: Optional[str] = None) -> List[VpnSession]:
"""查询VPN在线会话
做什么:获取当前通过aTrust在线的VPN会话
为什么:坐席需要知道远程员工VPN状态和IP
仅aTrust支持。
Args:
username: 可选,过滤指定用户
Returns:
VPN会话列表
"""
return []
async def get_online_status(self, username: str) -> bool:
"""查询用户是否在线
做什么:检查用户终端是否当前在线
为什么:坐席需要知道用户是否可达
各系统实现方式:
- 联软:existOnlineUser
- 火绒:_list(is_online=True) + IP交叉匹配
- aTrustgetUserStatus
Args:
username: 员工账号
Returns:
True=在线, False=离线或未知
"""
return False
# =========================================================================
# 辅助方法
# =========================================================================
def _log_not_implemented(self, method_name: str) -> None:
"""记录未实现方法的调试日志
做什么:当子类未覆写某个方法时记录DEBUG级日志
为什么:开发期帮助发现调用链路问题,生产环境可关闭DEBUG
"""
logger.debug(
f"[{self.system_name}] {method_name} 未实现,"
f"将走降级逻辑"
)
+176
View File
@@ -0,0 +1,176 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统数据缓存层
# =============================================================================
# 说明:
# 1. 封装外部系统数据的缓存读写逻辑
# 2. 统一缓存key格式:ext:{system}:{method}:{param_hash}
# 3. 不同数据类型使用不同TTL(终端映射30分钟、安全状态5分钟等)
# 4. Redis不可用时自动降级(不缓存,直接透传)
#
# 与 CacheService 的关系:
# CacheService 是全局Redis客户端封装,ExternalSystemCache 基于它
# 添加外部系统专用的缓存策略(TTL、key格式、刷新机制)
# =============================================================================
import hashlib
import json
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from app.services.cache_service import CacheService
logger = logging.getLogger(__name__)
# =============================================================================
# 缓存TTL配置(秒)
# =============================================================================
CACHE_TTL = {
# 终端映射(员工→终端)— 映射关系不常变,缓存较长
"terminal_mapping": 30 * 60, # 30分钟
# 终端详情(硬件/软件)— 硬件配置极少变,缓存最长
"terminal_detail": 60 * 60, # 60分钟
# 安全状态(漏洞/病毒)— 安全状态需近实时,缓存短
"security_status": 5 * 60, # 5分钟
# VPN在线状态 — 在线状态变化快,缓存最短
"vpn_status": 1 * 60, # 1分钟
# eHR员工信息 — 静态数据,缓存最长
"employee_info": 24 * 60 * 60, # 24小时
}
class ExternalSystemCache:
"""外部系统数据缓存
做什么:为外部系统查询结果提供统一缓存读写
为什么:减少外部API调用频率,降低延迟和出错率
降级策略:Redis不可用时,缓存读写均跳过,直接透传到外部系统
"""
def __init__(self, cache_service: Optional[CacheService] = None):
"""初始化缓存层
Args:
cache_service: Redis缓存服务实例。None时降级为无缓存模式
"""
self._cache = cache_service
@staticmethod
def _make_key(system: str, method: str, param: str) -> str:
"""生成缓存key
做什么:按统一格式生成缓存key
为什么:避免不同系统/方法的key冲突
Args:
system: 系统标识(lianruan/huorong/atrust/ehr
method: 方法名(terminal_mapping/terminal_detail/...
param: 查询参数(用户名/计算机名等)
Returns:
缓存key,格式: ext:lianruan:terminal_mapping:abc123
"""
# 对参数做哈希,避免特殊字符问题
param_hash = hashlib.md5(param.encode()).hexdigest()[:12]
return f"ext:{system}:{method}:{param_hash}"
async def get(self, system: str, method: str, param: str) -> Optional[Dict]:
"""从缓存读取数据
做什么:按系统+方法+参数查找缓存
为什么:命中缓存可避免一次外部API调用
Args:
system: 系统标识
method: 方法名
param: 查询参数
Returns:
缓存的字典数据,或 None(未命中/Redis不可用)
"""
if not self._cache or not self._cache.redis:
return None
key = self._make_key(system, method, param)
try:
data = await self._cache.get(key)
if data:
logger.debug(f"缓存命中: {key}")
return json.loads(data) if isinstance(data, str) else data
return None
except Exception as e:
# Redis错误不阻断业务,降级为无缓存
logger.warning(f"缓存读取失败(降级为无缓存): {key}, error={e}")
return None
async def set(
self,
system: str,
method: str,
param: str,
data: Dict,
ttl_override: Optional[int] = None,
) -> bool:
"""写入缓存
做什么:将外部系统查询结果存入缓存
为什么:后续相同查询可直接命中缓存
Args:
system: 系统标识
method: 方法名
param: 查询参数
data: 要缓存的数据
ttl_override: 自定义TTL(秒),None则使用默认TTL
Returns:
True=成功, False=失败/Redis不可用
"""
if not self._cache or not self._cache.redis:
return False
key = self._make_key(system, method, param)
ttl = ttl_override or CACHE_TTL.get(method, 5 * 60) # 默认5分钟
try:
# 添加缓存时间戳,便于判断数据新鲜度
data_with_meta = {
**data,
"_cached_at": datetime.now().isoformat(),
"_source_system": system,
}
await self._cache.set(key, json.dumps(data_with_meta, default=str), ex=ttl)
logger.debug(f"缓存写入: {key}, TTL={ttl}s")
return True
except Exception as e:
logger.warning(f"缓存写入失败: {key}, error={e}")
return False
async def invalidate(self, system: str, method: str, param: str) -> bool:
"""主动失效缓存
做什么:删除指定缓存条目
为什么:外部数据变更时(如终端隔离后),需主动失效缓存
Args:
system: 系统标识
method: 方法名
param: 查询参数
Returns:
True=成功, False=失败/Redis不可用
"""
if not self._cache or not self._cache.redis:
return False
key = self._make_key(system, method, param)
try:
await self._cache.delete(key)
logger.debug(f"缓存失效: {key}")
return True
except Exception as e:
logger.warning(f"缓存失效失败: {key}, error={e}")
return False
+166
View File
@@ -0,0 +1,166 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统连接配置管理
# =============================================================================
# 说明:
# 1. 统一管理联软/火绒/aTrust/eHR四个系统的连接配置
# 2. 支持从环境变量或 .env 文件读取
# 3. 支持运行时切换 Mock 模式(所有请求走 MockAdapter
#
# 配置优先级:环境变量 > .env 文件 > 默认值
# =============================================================================
import os
from typing import Optional
from pydantic import BaseModel, Field
class ExternalSystemConfig(BaseModel):
"""外部系统连接配置
做什么:集中管理所有外部系统的连接参数
为什么:避免在代码中硬编码,支持环境隔离(开发/测试/生产)
"""
# ── 联软LV7000 ──
lianruan_base_url: str = Field(
default="http://192.168.3.200:30098",
description="联软API基础地址(端口30098",
)
lianruan_api_account: Optional[str] = Field(
default=None,
description="联软API账号(ApiAccount参数)",
)
lianruan_api_password: Optional[str] = Field(
default=None,
description="联软API密码(ApiPassword参数)",
)
lianruan_enabled: bool = Field(
default=False,
description="联软适配器是否启用(凭证配置后自动启用)",
)
# ── 火绒企业版 ──
huorong_base_url: str = Field(
default="http://huorong.oa.servyou-it.com:8080",
description="火绒API基础地址(内网地址)",
)
huorong_access_key_id: Optional[str] = Field(
default=None,
description="火绒AccessKey ID",
)
huorong_access_key_secret: Optional[str] = Field(
default=None,
description="火绒AccessKey SecretHMAC-SHA1签名用)",
)
huorong_enabled: bool = Field(
default=False,
description="火绒适配器是否启用",
)
# ── aTrust零信任 ──
atrust_base_url: str = Field(
default="https://atrust.servyou-it.com:4433",
description="aTrust API基础地址(HTTPS端口4433",
)
atrust_api_id: Optional[str] = Field(
default=None,
description="aTrust API IDx-ca-key Header",
)
atrust_api_secret: Optional[str] = Field(
default=None,
description="aTrust API SecretHMAC-SHA256签名密钥)",
)
atrust_directory_domain: Optional[str] = Field(
default=None,
description="aTrust用户目录域名(V3 API需要此参数)",
)
atrust_enabled: bool = Field(
default=False,
description="aTrust适配器是否启用",
)
# ── 北森eHR ──
ehr_base_url: Optional[str] = Field(
default=None,
description="eHR API基础地址",
)
ehr_client_id: Optional[str] = Field(
default=None,
description="eHR OAuth2.0 Client ID",
)
ehr_client_secret: Optional[str] = Field(
default=None,
description="eHR OAuth2.0 Client Secret",
)
ehr_enabled: bool = Field(
default=False,
description="eHR适配器是否启用",
)
# ── 全局配置 ──
cache_enabled: bool = Field(
default=True,
description="是否启用外部数据缓存(Redis",
)
mock_mode: bool = Field(
default=False,
description="Mock模式 — True时所有请求走MockAdapter,不调真实API",
)
class Config:
env_prefix = "EXT_" # 环境变量前缀,如 EXT_LIANRUAN_BASE_URL
def load_external_config() -> ExternalSystemConfig:
"""从环境变量加载外部系统配置
做什么:读取 EXT_ 前缀的环境变量,构建配置对象
为什么:生产环境通过环境变量注入敏感配置,不写入代码或文件
Returns:
ExternalSystemConfig 实例
"""
config_dict = {}
# 映射关系:环境变量名 → 配置字段名
env_mapping = {
"EXT_LIANRUAN_BASE_URL": "lianruan_base_url",
"EXT_LIANRUAN_API_ACCOUNT": "lianruan_api_account",
"EXT_LIANRUAN_API_PASSWORD": "lianruan_api_password",
"EXT_HUORONG_BASE_URL": "huorong_base_url",
"EXT_HUORONG_ACCESS_KEY_ID": "huorong_access_key_id",
"EXT_HUORONG_ACCESS_KEY_SECRET": "huorong_access_key_secret",
"EXT_ATRUST_BASE_URL": "atrust_base_url",
"EXT_ATRUST_API_ID": "atrust_api_id",
"EXT_ATRUST_API_SECRET": "atrust_api_secret",
"EXT_ATRUST_DIRECTORY_DOMAIN": "atrust_directory_domain",
"EXT_EHR_BASE_URL": "ehr_base_url",
"EXT_EHR_CLIENT_ID": "ehr_client_id",
"EXT_EHR_CLIENT_SECRET": "ehr_client_secret",
"EXT_CACHE_ENABLED": "cache_enabled",
"EXT_MOCK_MODE": "mock_mode",
}
for env_key, field_name in env_mapping.items():
value = os.environ.get(env_key)
if value is not None:
# 布尔类型特殊处理
if field_name in ("cache_enabled", "mock_mode"):
config_dict[field_name] = value.lower() in ("true", "1", "yes")
else:
config_dict[field_name] = value
config = ExternalSystemConfig(**config_dict)
# 自动启用已有凭证的系统
if config.lianruan_api_account and config.lianruan_api_password:
config.lianruan_enabled = True
if config.huorong_access_key_id and config.huorong_access_key_secret:
config.huorong_enabled = True
if config.atrust_api_id and config.atrust_api_secret:
config.atrust_enabled = True
if config.ehr_client_id and config.ehr_client_secret:
config.ehr_enabled = True
return config
+223
View File
@@ -0,0 +1,223 @@
# =============================================================================
# 企微IT智能服务台 — Mock适配器(开发期使用)
# =============================================================================
# 说明:
# 1. 在 Mock 模式下(EXT_MOCK_MODE=True),所有外部系统查询
# 返回预置的 Mock 数据,不调用任何真实 API
# 2. Mock 数据覆盖 P0 场景(终端查询、安全状态、VPN在线)
# 3. 凭证未配置时自动降级到 MockAdapter,保证开发期无外部依赖
#
# 使用方式:
# EXT_MOCK_MODE=True → 所有系统走 Mock
# 某系统凭证未配置 → 单个系统自动降级到 Mock(在 service.py 中处理)
# =============================================================================
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from app.services.external.base import (
ExternalSystemAdapter,
TerminalInfo,
SecurityStatus,
VpnSession,
)
logger = logging.getLogger(__name__)
# =============================================================================
# Mock 数据工厂
# =============================================================================
def _make_mock_terminal(username: str) -> TerminalInfo:
"""生成 Mock 终端信息
做什么:为指定用户生成一个逼真的模拟终端数据
为什么:开发期没有真实凭证时需要终端数据支撑会话排查流程
"""
return TerminalInfo(
source_system="mock",
terminal_id=f"mock-terminal-{username}",
computer_name=f"{username.upper()}-PC01",
ip_addresses=[f"192.168.{hash(username) % 255}.{100 + hash(username) % 155}"],
mac_addresses=[f"00:16:3E:{hash(username) % 256:02X}:{hash(username + 'a') % 256:02X}:{hash(username + 'b') % 256:02X}"],
os_version="Windows 11 专业版 23H2",
is_online=True,
logged_in_user=username,
logged_in_user_name=_username_to_display_name(username),
department=_guess_department(username),
hardware_summary={
"cpu": "Intel Core i7-12700",
"memory_total_gb": 16,
"memory_used_gb": 8,
"disk_total_gb": 512,
"disk_free_gb": 128,
"disk_usage_pct": 75, # 模拟磁盘使用率较高
},
last_seen=datetime.now() - timedelta(minutes=5),
raw_data=None, # Mock 数据不保留原始响应
)
def _make_mock_security_status(terminal_id: str) -> SecurityStatus:
"""生成 Mock 安全状态
做什么:生成一个模拟的安全状态数据
为什么:开发期需要验证安全状态卡片、漏洞警告等UI渲染
"""
return SecurityStatus(
source_system="mock",
terminal_id=terminal_id,
computer_name=f"MOCK-PC01",
virus_total=2,
virus_uncleaned=1,
vulnerabilities=[
{
"name": "Microsoft Windows 安全更新 (CVE-2025-12345)",
"level": "high",
"description": "远程代码执行漏洞,需立即修补",
"publish_time": (datetime.now() - timedelta(days=7)).isoformat(),
},
{
"name": "火绒安全漏洞扫描:弱密码检测",
"level": "medium",
"description": "账户密码强度不足,建议修改",
"publish_time": (datetime.now() - timedelta(days=3)).isoformat(),
},
],
high_vuln_count=1,
is_isolated=False,
isolation_source=None,
checked_at=datetime.now(),
)
def _make_mock_vpn_session(username: str) -> VpnSession:
"""生成 Mock VPN 会话"""
return VpnSession(
source_system="mock",
session_id=f"mock-session-{username}",
username=username,
display_name=_username_to_display_name(username),
remote_ip=f"1{hash(username) % 100}.{hash(username + 'r') % 256}.{hash(username + 's') % 256}.{hash(username + 't') % 256}",
vpn_ip=f"10.200.{hash(username) % 255}.{100 + hash(username) % 155}",
is_trusted=True,
os="Windows 11",
last_login=datetime.now() - timedelta(minutes=30),
domain="servyou.local",
)
def _username_to_display_name(username: str) -> str:
"""Mock 用户名转换(简单映射)"""
name_map = {
"songxian": "宋献",
"zhangsan": "张三",
"lisi": "李四",
"wangwu": "王五",
}
return name_map.get(username, username)
def _guess_department(username: str) -> str:
"""Mock 部门推断"""
dept_map = {
"songxian": "IT支持组",
"zhangsan": "财务部",
"lisi": "人力资源部",
"wangwu": "研发部",
}
return dept_map.get(username, "未知部门")
# =============================================================================
# MockAdapter 实现
# =============================================================================
class MockAdapter(ExternalSystemAdapter):
"""Mock 适配器 — 开发期替代所有外部系统
做什么:提供逼真的模拟数据,让开发期可以不依赖任何外部系统
为什么:阶段一MVP验证、前端开发、单元测试都需要稳定的数据来源
降级规则:
- 所有方法均返回 Mock 数据
- 支持常用测试用户:songxian / zhangsan / lisi / wangwu
- is_available 固定返回 TrueMock 永远可用)
"""
@property
def system_name(self) -> str:
return "mock"
@property
def is_available(self) -> bool:
"""Mock 永远可用"""
return True
async def health_check(self) -> bool:
"""Mock 健康检查永远通过"""
logger.debug("[MockAdapter] 健康检查 → OKMock模式)")
return True
# ── 终端查询能力 ──
async def get_terminal_by_user(self, username: str) -> Optional[TerminalInfo]:
"""Mock:通过账号查询终端
做什么:返回预置的 Mock 终端信息
为什么:开发期坐席打开会话时需要看到终端画像
"""
logger.info(f"[MockAdapter] get_terminal_by_user({username}) → Mock数据")
return _make_mock_terminal(username)
async def get_terminal_by_computer(self, computer_name: str) -> Optional[TerminalInfo]:
"""Mock:通过计算机名查询终端"""
logger.info(f"[MockAdapter] get_terminal_by_computer({computer_name}) → Mock数据")
# 从计算机名反推用户名(简单逻辑)
username = computer_name.split("-")[0].lower() if "-" in computer_name else "songxian"
return _make_mock_terminal(username)
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
"""Mock:查询终端详细信息"""
logger.info(f"[MockAdapter] get_terminal_detail({terminal_id}) → Mock数据")
return _make_mock_terminal("songxian")
# ── 安全能力 ──
async def get_security_status(self, terminal_id: str) -> Optional[SecurityStatus]:
"""Mock:获取安全状态"""
logger.info(f"[MockAdapter] get_security_status({terminal_id}) → Mock数据")
return _make_mock_security_status(terminal_id)
async def isolate_terminal(self, terminal_id: str, reason: str) -> bool:
"""Mock:隔离终端(Mock 模式仅记录日志)"""
logger.warning(
f"[MockAdapter] 隔离终端(Mock,不真实执行): "
f"terminal={terminal_id}, reason={reason}"
)
return True # Mock 永远返回成功
async def unisolate_terminal(self, terminal_id: str) -> bool:
"""Mock:解除隔离"""
logger.warning(
f"[MockAdapter] 解除隔离(Mock,不真实执行): terminal={terminal_id}"
)
return True
# ── VPN/在线状态 ──
async def get_vpn_sessions(self, username: Optional[str] = None) -> List[VpnSession]:
"""Mock:查询VPN在线会话"""
if username:
return [_make_mock_vpn_session(username)]
# 返回多个 Mock 会话
return [
_make_mock_vpn_session("songxian"),
_make_mock_vpn_session("zhangsan"),
]
async def get_online_status(self, username: str) -> bool:
"""Mock:查询在线状态(Mock 永远返回 True)"""
return True
+491
View File
@@ -0,0 +1,491 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统统一门面服务
# =============================================================================
# 说明:
# 1. 上层业务代码(AI Wingman、会话管理等)只依赖此类
# 2. 按优先级链式查询(联软 → aTrust → eHR
# 3. 自动处理降级(系统不可用时跳到下一个)
# 4. 所有方法均有详细行内注释(做什么 + 为什么)
#
# 使用方式:
# from app.services.external import get_external_service
# svc = get_external_service()
# terminal = await svc.find_user_terminal("songxian")
# =============================================================================
import logging
from typing import Any, Dict, List, Optional
from app.services.cache_service import CacheService
from app.services.external.base import (
ExternalSystemAdapter,
TerminalInfo,
SecurityStatus,
VpnSession,
)
from app.services.external.config import ExternalSystemConfig
from app.services.external.cache import ExternalSystemCache
from app.services.external.mock import MockAdapter
logger = logging.getLogger(__name__)
# =============================================================================
# 全局单例(懒加载)
# =============================================================================
_external_service_instance: Optional["ExternalSystemService"] = None
def get_external_service() -> "ExternalSystemService":
"""获取外部系统服务单例
做什么:返回全局唯一的 ExternalSystemService 实例
为什么:避免重复初始化 Adapter,节省连接资源
"""
global _external_service_instance
if _external_service_instance is None:
raise RuntimeError(
"ExternalSystemService 尚未初始化,"
"请在应用启动时调用 init_external_service()"
)
return _external_service_instance
def init_external_service(
config: Optional[ExternalSystemConfig] = None,
cache_service: Optional[Any] = None,
) -> ExternalSystemService:
"""初始化外部系统服务(应用启动时调用一次)
做什么:根据配置创建所有 Adapter,组装成 ExternalSystemService
为什么:集中初始化,避免分散在各处创建 Adapter 实例
Args:
config: 外部系统配置,None 时自动从环境变量加载
cache_service: Redis 缓存服务实例,None 时降级为无缓存
Returns:
初始化完成的 ExternalSystemService 实例
"""
global _external_service_instance
if config is None:
from app.services.external.config import load_external_config
config = load_external_config()
# 创建缓存层
cache = ExternalSystemCache(cache_service) if cache_service else None
# 按优先级组装 Adapter 字典
adapters: Dict[str, ExternalSystemAdapter] = {}
if config.mock_mode:
# Mock 模式:所有系统走 MockAdapter
logger.info("[External] Mock模式已启用,所有外部系统查询走Mock数据")
mock = MockAdapter()
adapters = {
"lianruan": mock,
"huorong": mock,
"atrust": mock,
"ehr": mock,
}
else:
# ── 联软(主映射源P0)─────────────────────────────
# 做什么:创建联软 Adapter(凭证已配置时启用)
# 为什么:联软的 strusername 字段是员工→终端映射最可靠来源
if config.lianruan_enabled:
from app.services.external.lianruan_adapter import LianruanAdapter
adapters["lianruan"] = LianruanAdapter(config)
logger.info("[External] 联软适配器已启用")
else:
logger.warning(
"[External] 联软适配器未启用(凭证未配置),"
"终端映射功能将降级"
)
# ── 火绒(安全源P0)─────────────────────────────────
# 做什么:创建火绒 Adapter(凭证已配置时启用)
# 为什么:火绒提供终端安全状态(病毒/漏洞/隔离),不参与映射
if config.huorong_enabled:
from app.services.external.huorong_adapter import HuorongAdapter
adapters["huorong"] = HuorongAdapter(config)
logger.info("[External] 火绒适配器已启用")
else:
logger.info("[External] 火绒适配器未启用(凭证未配置)")
# ── aTrust(VPN源P1)────────────────────────────────
# 做什么:创建 aTrust Adapter(凭证已配置时启用)
# 为什么:aTrust 提供 VPN 在线状态和虚拟IP,用于远程员工排查
if config.atrust_enabled:
from app.services.external.atrust_adapter import ATrustAdapter
adapters["atrust"] = ATrustAdapter(config)
logger.info("[External] aTrust适配器已启用")
else:
logger.info("[External] aTrust适配器未启用(凭证未配置)")
# ── eHR(辅助静态数据P2)───────────────────────────
# 做什么:创建 eHR Adapter(凭证已配置时启用)
# 为什么:eHR 提供员工基础信息和任职信息,作为静态数据补充
if config.ehr_enabled:
from app.services.external.ehr_adapter import EHRAdapter
adapters["ehr"] = EHRAdapter(config)
logger.info("[External] eHR适配器已启用")
else:
logger.info("[External] eHR适配器未启用(凭证未配置)")
_external_service_instance = ExternalSystemService(adapters, cache)
logger.info(
f"[External] 服务初始化完成,已加载适配器: "
f"{list(adapters.keys())}"
)
return _external_service_instance
# =============================================================================
# 统一门面服务
# =============================================================================
class ExternalSystemService:
"""外部系统统一门面 — 上层业务唯一依赖的入口
做什么:按优先级链式查询外部系统,对上层屏蔽底层差异
为什么:上层代码不需要知道终端数据来自联软还是火绒
查询优先级(映射场景):
1. 联软(主源,strusername 精确匹配)
2. aTrustVPN源,bindUserList 匹配)
3. eHR(静态辅助,无终端数据,返回None)
安全能力(仅火绒):
- 获取安全状态(病毒/漏洞)
- 隔离/解除终端(需admin角色+二次确认)
VPN能力(仅aTrust):
- 查询在线会话
- 踢出用户
"""
def __init__(
self,
adapters: Dict[str, ExternalSystemAdapter],
cache: Optional[ExternalSystemCache] = None,
):
"""初始化统一门面
Args:
adapters: 系统标识 → Adapter 实例 的字典
cache: 外部数据缓存层(可为None,降级为无缓存)
"""
self._adapters = adapters
self._cache = cache
# =========================================================================
# 终端查询(映射核心)
# =========================================================================
async def find_user_terminal(self, username: str) -> Optional[TerminalInfo]:
"""查找用户终端 — 按优先级链式查询
做什么:根据员工账号查找其使用的终端信息
为什么:这是员工→终端映射的核心入口,坐席排查时首先需要知道
员工用哪台电脑
查询顺序:
1. 联软 queryDevByParams(strusername=xxx) — 最精确
2. aTrust queryAll(bindUserList) — VPN 场景补充
3. eHR — 无终端数据,返回 None
降级:某系统不可用时自动跳过,不影响整体结果。
Args:
username: 员工账号(如 'songxian'
Returns:
TerminalInfo 或 None(所有系统均未找到)
"""
logger.info(f"[External] 查找用户终端: username={username}")
# ── 第1优先级:联软(主映射源)────────────────────
# 做什么:优先用联软查,它有 strusername 精确字段
# 为什么:联软直接建立员工账号→终端的映射,比IP交叉匹配可靠
lianruan = self._adapters.get("lianruan")
if lianruan and lianruan.is_available:
try:
result = await self._query_with_cache(
"lianruan", "get_terminal_by_user", username
)
if result:
logger.info(
f"[External] 联软命中: username={username}, "
f"computer={result.computer_name}"
)
return result
except Exception as e:
# 联软不可用 → 降级到 aTrust,不阻断
logger.warning(
f"[External] 联软查询失败(降级到aTrust): {e}"
)
else:
logger.debug("[External] 联软不可用或未启用,跳过")
# ── 第2优先级:aTrust(VPN源)─────────────────────
# 做什么:联软未命中时,用 aTrust 查 VPN 终端
# 为什么:远程办公员工可能不在联软覆盖范围内
atrust = self._adapters.get("atrust")
if atrust and atrust.is_available:
try:
result = await self._query_with_cache(
"atrust", "get_terminal_by_user", username
)
if result:
logger.info(
f"[External] aTrust命中: username={username}, "
f"vpn_ip={result.ip_addresses}"
)
return result
except Exception as e:
logger.warning(
f"[External] aTrust查询失败(降级到eHR: {e}"
)
else:
logger.debug("[External] aTrust不可用或未启用,跳过")
# ── 第3优先级:eHR(辅助静态数据)────────────────
# 做什么:eHR 不提供终端数据,此方法返回 None
# 为什么:保留接口一致性,未来可能扩展
ehr = self._adapters.get("ehr")
if ehr and ehr.is_available:
result = await self._query_with_cache(
"ehr", "get_terminal_by_user", username
)
if result:
return result
# 所有系统均未命中
logger.info(f"[External] 所有系统均未找到用户终端: username={username}")
return None
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
"""查询终端详细信息(硬件/软件/网络配置)
做什么:获取比 find_user_terminal 更详细的终端信息
为什么:排查硬件故障(卡慢)时需要CPU/内存/磁盘使用率等数据
优先联软(getDevAllInfo 比火绒 _info2 更详细)。
Args:
terminal_id: 终端在来源系统中的唯一ID
Returns:
TerminalInfo(含 hardware_summary)或 None
"""
# 联软详细信息最全(含主板/CPU/内存/硬盘/显示器)
lianruan = self._adapters.get("lianruan")
if lianruan and lianruan.is_available:
try:
return await lianruan.get_terminal_detail(terminal_id)
except Exception as e:
logger.warning(f"[External] 联软详细信息查询失败: {e}")
# 火绒作为备选(_info2 含硬件/软件/网络配置)
huorong = self._adapters.get("huorong")
if huorong and huorong.is_available:
try:
return await huorong.get_terminal_detail(terminal_id)
except Exception as e:
logger.warning(f"[External] 火绒详细信息查询失败: {e}")
return None
# =========================================================================
# 安全能力(仅火绒)
# =========================================================================
async def get_terminal_security(self, terminal_id: str) -> Optional[SecurityStatus]:
"""获取终端安全状态
做什么:查询终端的病毒事件、高危漏洞、隔离状态
为什么:坐席排查安全问题时需要一目了然
仅火绒支持此能力。
Args:
terminal_id: 火绒的 client_id
Returns:
SecurityStatus 或 None(火绒不可用)
"""
huorong = self._adapters.get("huorong")
if not huorong or not huorong.is_available:
logger.warning("[External] 火绒不可用,无法获取安全状态")
return None
try:
return await self._query_with_cache(
"huorong", "get_security_status", terminal_id
)
except Exception as e:
logger.error(f"[External] 获取安全状态失败: {e}")
return None
async def isolate_terminal(
self, terminal_id: str, reason: str, operator: str
) -> bool:
"""隔离终端(断网)
做什么:调用火绒 _create(type=netctrl) 隔离终端
为什么:安全事件紧急处理,阻断威胁扩散
仅火绒支持。调用前必须在上层做:
1. 操作者角色校验(仅 admin 可操作)
2. 二次确认弹窗
3. 审计日志记录
Args:
terminal_id: 火绒的 client_id
reason: 隔离原因(记入审计日志)
operator: 操作者账号
Returns:
True=成功, False=失败
"""
huorong = self._adapters.get("huorong")
if not huorong or not huorong.is_available:
logger.error("[External] 火绒不可用,无法执行隔离")
return False
logger.warning(
f"[External] 执行终端隔离: terminal={terminal_id}, "
f"operator={operator}, reason={reason}"
)
try:
return await huorong.isolate_terminal(terminal_id, reason)
except Exception as e:
logger.error(f"[External] 隔离失败: {e}")
return False
async def unisolate_terminal(self, terminal_id: str) -> bool:
"""解除终端隔离(恢复网络)"""
huorong = self._adapters.get("huorong")
if not huorong or not huorong.is_available:
return False
try:
return await huorong.unisolate_terminal(terminal_id)
except Exception as e:
logger.error(f"[External] 解除隔离失败: {e}")
return False
# =========================================================================
# VPN/在线状态(仅aTrust
# =========================================================================
async def get_vpn_sessions(
self, username: Optional[str] = None
) -> List[VpnSession]:
"""查询VPN在线会话
做什么:获取当前通过aTrust在线的VPN会话列表
为什么:坐席需要知道远程员工是否在线、VPN IP是什么
仅aTrust支持。
Args:
username: 可选,过滤指定用户的会话
Returns:
VPN会话列表(可能为空)
"""
atrust = self._adapters.get("atrust")
if not atrust or not atrust.is_available:
return []
try:
return await atrust.get_vpn_sessions(username)
except Exception as e:
logger.warning(f"[External] 查询VPN会话失败: {e}")
return []
async def get_online_status(self, username: str) -> bool:
"""查询用户是否在线
做什么:检查用户当前是否在线(任何方式接入)
为什么:坐席发起协作或推送消息前需要知道用户是否可达
Args:
username: 员工账号
Returns:
True=在线, False=离线或未知
"""
# 优先联软(内网接入)
lianruan = self._adapters.get("lianruan")
if lianruan and lianruan.is_available:
try:
if await lianruan.get_online_status(username):
return True
except Exception:
pass
# 再查 aTrustVPN接入)
atrust = self._adapters.get("atrust")
if atrust and atrust.is_available:
try:
if await atrust.get_online_status(username):
return True
except Exception:
pass
return False
# =========================================================================
# 内部方法
# =========================================================================
async def _query_with_cache(
self, system: str, method: str, param: str
) -> Any:
"""带缓存的查询(内部方法)
做什么:先查缓存,未命中则调Adapter,结果写回缓存
为什么:减少外部API调用频率,降低延迟
Args:
system: 系统标识
method: 方法名(用于缓存key
param: 查询参数(用于缓存key)
Returns:
Adapter返回的数据(可能经缓存)
"""
# 步骤1:尝试从缓存读取
if self._cache:
cached = await self._cache.get(system, method, param)
if cached:
return cached
# 步骤2:缓存未命中,调用Adapter
adapter = self._adapters.get(system)
if not adapter:
raise RuntimeError(f"Adapter不存在: {system}")
method_map = {
"get_terminal_by_user": adapter.get_terminal_by_user,
"get_terminal_by_computer": adapter.get_terminal_by_computer,
"get_terminal_detail": adapter.get_terminal_detail,
"get_security_status": adapter.get_security_status,
"get_vpn_sessions": adapter.get_vpn_sessions,
"get_online_status": adapter.get_online_status,
}
if method not in method_map:
raise RuntimeError(f"未知方法: {method}")
result = await method_map[method](param)
# 步骤3:写入缓存(仅非None结果)
if result and self._cache:
# 转成可序列化的字典
data = result.dict() if hasattr(result, "dict") else result
await self._cache.set(system, method, param, data)
return result
@@ -0,0 +1,157 @@
# =============================================================================
# 企微IT智能服务台 — 趣味话术服务
# =============================================================================
# 说明:管理各场景的趣味话术,包括:
# 1. 根据触发场景返回对应话术
# 2. 从 funny_phrases 表读取话术配置
# 3. 支持按员工 VIP 等级自动切换话术(VIP → 正式版话术)
# 4. 预置 6 种场景的默认话术
# =============================================================================
import logging
import random
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.funny_phrase import FunnyPhrase
logger = logging.getLogger(__name__)
class FunnyPhraseService:
"""趣味话术服务。
根据触发场景返回对应的趣味话术。
支持后台动态修改话术内容(通过 funny_phrases 表)。
"""
# 默认话术(当数据库未配置时使用,和 PRD 一致)
DEFAULT_PHRASES = {
"shake": "大哥,俺这就去摇人,稍等...",
"keyword": "收到!这就帮您摇位大神来",
"waiting": "人还在路上,别急别急~",
"connected": "人摇来了!IT坐席为您服务",
"timeout": "坐席都在忙,不过AI还在呢,要不先聊聊?我再继续摇",
"vip": "这就帮您安排专家,请稍候",
}
def __init__(self, db: AsyncSession):
"""初始化趣味话术服务。
Args:
db: 异步数据库会话
"""
self.db = db
# --------------------------------------------------------------------------
# 获取话术
# --------------------------------------------------------------------------
async def get_phrase(
self, scene: str, is_vip: bool = False
) -> str:
"""根据触发场景获取趣味话术。
优先从 funny_phrases 表读取,如果未配置则使用默认话术。
VIP 员工自动使用 "vip" 场景的话术。
场景说明:
- click_shake / shake: 点击摇人按钮
- keyword: 关键词触发转人工
- waiting: 排队等待(30秒无人接单)
- connected: 坐席接入
- timeout: 等待超时(2分钟)
- vip: VIP员工专用
Args:
scene: 触发场景(shake/keyword/waiting/connected/timeout/vip
is_vip: 是否 VIP 员工(VIP 优先使用 vip 场景话术)
Returns:
str: 话术内容
"""
# VIP 员工优先使用 vip 场景话术
actual_scene = scene
if is_vip and scene != "vip":
# 尝试获取 VIP 话术,如果不存在则回退到原场景
vip_phrase = await self._get_phrase_from_db("vip")
if vip_phrase:
logger.debug(f"VIP员工使用专属话术: scene=vip")
return vip_phrase
# 从数据库获取对应场景的话术
phrase = await self._get_phrase_from_db(actual_scene)
if phrase:
return phrase
# 数据库未配置,使用默认话术
default = self.DEFAULT_PHRASES.get(actual_scene, "请稍候...")
logger.debug(f"使用默认话术: scene={actual_scene}")
return default
# --------------------------------------------------------------------------
# 从数据库获取话术
# --------------------------------------------------------------------------
async def _get_phrase_from_db(self, scene: str) -> Optional[str]:
"""从 funny_phrases 表获取指定场景的话术。
同一场景可能有多条话术,随机返回一条(增加趣味性)。
只返回 is_active=True 的话术。
Args:
scene: 触发场景
Returns:
Optional[str]: 话术内容,未找到返回 None
"""
stmt = (
select(FunnyPhrase)
.where(
FunnyPhrase.scene == scene,
FunnyPhrase.is_active == True,
)
.order_by(FunnyPhrase.sort_order)
)
result = await self.db.execute(stmt)
phrases = list(result.scalars().all())
if not phrases:
return None
# 随机选一条(如果有多个话术,增加随机趣味性)
chosen = random.choice(phrases)
return chosen.content
# --------------------------------------------------------------------------
# 获取所有场景的话术
# --------------------------------------------------------------------------
async def get_all_phrases(self) -> dict:
"""获取所有场景的话术。
用于后台管理页面展示当前话术配置。
Returns:
dict: 按场景分组的话术字典
"""
stmt = select(FunnyPhrase).order_by(
FunnyPhrase.scene, FunnyPhrase.sort_order
)
result = await self.db.execute(stmt)
phrases = list(result.scalars().all())
# 按场景分组
grouped: dict = {}
for phrase in phrases:
if phrase.scene not in grouped:
grouped[phrase.scene] = []
grouped[phrase.scene].append({
"id": str(phrase.id),
"content": phrase.content,
"tone": phrase.tone,
"sort_order": phrase.sort_order,
"is_active": phrase.is_active,
})
return grouped
+671
View File
@@ -0,0 +1,671 @@
# =============================================================================
# 企微IT智能服务台 — 消息路由核心服务
# =============================================================================
# 说明:消息路由层是整个系统的"大脑",负责:
# 1. 接收企微回调消息,路由到不同处理逻辑
# 2. 查找或创建会话
# 3. AI 自动回复(新会话 / AI 处理中的会话)
# 4. 触发 VIP 检测
# 5. 触发标记检测(举手/需介入/情绪)
# 6. 触发紧急度评分
# 7. 消息入库
#
# 路由策略(含 AI):
# - 新会话 → ai_handling → AIHandler 处理 → 命中回复 / 未命中转 queued
# - AI 处理中的会话 → AIHandler 处理 → 命中回复 / 未命中转 queued
# - 排队中/服务中的会话 → 追加消息(坐席人工处理)
#
# 重构记录(2026-06):
# - 替换 ai_service 为 ai_handler(统一 AI 调用逻辑)
# - AIHandler 包含打招呼检测和呼叫人工拦截,两端行为完全一致
# - 举手检测仅用于标记,不再强制跳过 AI(由 AIHandler 统一处理呼叫人工)
# =============================================================================
import json
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.message import Message
from app.services.ai_handler import AIHandler, AIReplyResult
from app.services.cache_service import CacheService
from app.services.scoring_service import ScoringService
from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
class MessageRouter:
"""消息路由核心服务。
接收企微回调消息后,按流程处理:
1. find_or_create_conversation — 查找或创建会话(新会话默认 ai_handling
2. AI 自动回复(仅对 ai_handling 状态的会话,通过 AIHandler 统一处理)
3. VIP 检测(从企微通讯录获取员工信息)
4. 标记检测(举手/情绪/需介入)
5. 紧急度评分
6. 更新会话标记和紧急度
7. 创建消息记录
"""
def __init__(
self,
db: AsyncSession,
wecom_service: WecomService,
scoring_service: ScoringService,
ai_handler: Optional[AIHandler] = None,
cache_service: Optional[CacheService] = None,
):
"""初始化消息路由器。
Args:
db: 异步数据库会话
wecom_service: 企微 API 服务(发送消息、获取用户信息)
scoring_service: 评分服务(标记检测 + 紧急度计算)
ai_handler: AI 处理器(可选,为 None 时跳过 AI 处理)
cache_service: 缓存服务(可选,为 None 时跳过去重检查)
"""
self.db = db
self.wecom_service = wecom_service
self.scoring_service = scoring_service
self.ai_handler = ai_handler
self.cache_service = cache_service
# --------------------------------------------------------------------------
# 路由消息(核心入口方法)
# --------------------------------------------------------------------------
async def route_message(
self,
from_user_id: str,
content: str,
msg_type: str = "text",
msg_id: Optional[str] = None,
# 非文本消息扩展参数(轻量版:只存元数据,不下载媒体文件)
media_id: Optional[str] = None,
extra_data: Optional[Dict[str, Any]] = None,
file_name: Optional[str] = None,
file_size: Optional[int] = None,
) -> Optional[Conversation]:
"""路由消息的核心方法。
处理流程:
0. 消息去重检查(MsgId 去重 + 用户+内容去重)
1. 非文本消息 → _handle_non_text_message(自动回复 + 入库,不触发 AI)
2. 文本消息:
a. 查找或创建会话(新会话默认 ai_handling
b. AI 自动回复(仅对 ai_handling 状态的会话,通过 AIHandler 统一处理)
c. VIP 检测
d. 标记检测(举手/情绪/需介入)
e. 紧急度评分
f. 更新会话
g. 创建消息记录
h. 广播 WebSocket 事件
重构说明:举手检测不再强制跳过 AI,由 AIHandler 统一处理呼叫人工拦截。
举手关键词仍用于设置 tag(影响紧急度评分),但不影响 AI 调用决策。
Args:
from_user_id: 发送消息的员工企微 UserID
content: 消息内容(非文本消息时可能为空)
msg_type: 消息类型(默认 text
msg_id: 企微消息唯一 IDMsgId),用于去重
media_id: 企微媒体文件ID(非文本消息时使用)
extra_data: 扩展元数据(pic_url/format/location 等)
file_name: 文件名(文件消息时使用)
file_size: 文件大小(字节,文件消息时使用)
Returns:
Optional[Conversation]: 更新后的会话对象,去重命中时返回 None
"""
logger.info(
f"收到员工消息: employee_id={from_user_id}, "
f"content={content[:50]}{'...' if len(content) > 50 else ''}, "
f"msg_type={msg_type}, msg_id={msg_id}"
)
# ----------------------------------------------------------
# 0. 消息去重检查(幂等保护,防止企微重复推送)
# ----------------------------------------------------------
if self.cache_service:
# 0a. 基于 MsgId 去重(与企微重试窗口一致,5 分钟)
if msg_id and await self.cache_service.is_duplicate(msg_id):
logger.info(
f"MsgId 去重命中,跳过处理: msg_id={msg_id}, "
f"from_user_id={from_user_id}"
)
return None
# 0b. 基于 user_id + content 去重(防快速重复发送,60 秒窗口)
if content and await self.cache_service.is_duplicate_content(
user_id=from_user_id, content=content
):
logger.info(
f"内容去重命中,跳过处理: from_user_id={from_user_id}, "
f"content={content[:30]}{'...' if len(content) > 30 else ''}"
)
return None
# 非文本消息走独立处理路径(不触发 AI、不评分、不标记检测)
if msg_type != "text":
return await self._handle_non_text_message(
from_user_id=from_user_id,
content=content,
msg_type=msg_type,
media_id=media_id,
extra_data=extra_data,
file_name=file_name,
file_size=file_size,
)
# 1. 查找或创建会话(新会话默认 ai_handling
conversation = await self._find_or_create_conversation(
from_user_id, content
)
# 2. 举手检测(仅用于标记,不跳过 AI)
is_hand_raise = self.scoring_service.detect_hand_raise(content)
# 3. AI 自动回复(仅对 ai_handling 状态的会话)
# AIHandler 内部会处理打招呼/呼叫人工/AI调用,统一行为
ai_replied = False
if (
self.ai_handler
and conversation.status == "ai_handling"
):
ai_replied = await self._try_ai_reply(
conversation=conversation,
content=content,
from_user_id=from_user_id,
)
# 4. VIP 检测(只在会话首次创建或未检测过时执行)
if not conversation.is_vip and conversation.department == "":
await self._check_vip(conversation)
# 5. 标记检测
tags = dict(conversation.tags) if conversation.tags else {}
# 5a. 举手标记检测
if is_hand_raise:
tags["hand_raise"] = True
logger.info(f"举手标记触发: employee_id={from_user_id}")
# 5b. 情绪标记检测
emotion = self.scoring_service.detect_emotion(content)
if emotion != "neutral":
tags["emotion"] = emotion
# 记录触发情绪标记的关键词
emotion_keywords = self.scoring_service.get_emotion_keywords(content, emotion)
if emotion_keywords:
tags["emotion_keywords"] = emotion_keywords
logger.info(f"情绪标记触发: employee_id={from_user_id}, emotion={emotion}")
# 5c. 需介入标记检测(基于追问轮次)
is_need_intervene = await self.scoring_service.detect_need_intervene(
conversation.id, self.db
)
if is_need_intervene:
tags["need_intervene"] = True
logger.info(f"需介入标记触发: employee_id={from_user_id}")
# 5d. 更新追问轮次计数
repeat_count = tags.get("repeat_count", 0)
tags["repeat_count"] = repeat_count + 1
# 6. 紧急度评分
urgency_score = await self.scoring_service.calculate_urgency(
content=content,
tags=tags,
is_vip=conversation.is_vip,
)
logger.info(
f"会话标记更新: conv_id={conversation.id}, "
f"tags={json.dumps(tags, ensure_ascii=False)}, urgency={urgency_score}"
)
# 7. 更新会话
conversation.tags = tags
conversation.urgency_score = urgency_score
conversation.last_message_at = datetime.now()
conversation.last_message_summary = content[:256]
conversation.updated_at = datetime.now()
self.db.add(conversation)
await self.db.flush()
# 8. 创建消息记录(员工消息)
message = Message(
conversation_id=conversation.id,
sender_type="employee",
sender_id=from_user_id,
sender_name=conversation.employee_name,
content=content,
msg_type=msg_type,
is_read=False,
)
self.db.add(message)
await self.db.flush()
logger.info(
f"消息路由完成: conv_id={conversation.id}, "
f"status={conversation.status}, urgency={urgency_score}, "
f"ai_replied={ai_replied}"
)
# ----------------------------------------------------------------------
# 9. 广播 WebSocket 事件
# ----------------------------------------------------------------------
from app.services.ws_manager import manager as ws_manager
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(message.id),
"sender_type": "employee",
"sender_id": from_user_id,
"content": content,
"urgency_score": urgency_score,
"tags": tags,
"ai_replied": ai_replied,
}
})
except Exception as e:
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
return conversation
# --------------------------------------------------------------------------
# 非文本消息处理(轻量版:自动回复 + 入库,不触发 AI)
# --------------------------------------------------------------------------
async def _handle_non_text_message(
self,
from_user_id: str,
content: str,
msg_type: str,
media_id: Optional[str] = None,
extra_data: Optional[Dict[str, Any]] = None,
file_name: Optional[str] = None,
file_size: Optional[int] = None,
) -> Conversation:
"""处理非文本消息(图片/语音/视频/文件/位置)。
轻量版策略:
- 图片:礼貌回复引导用户补充文字描述
- 其余类型:统一回复暂不支持
- 所有消息存入数据库
- 不触发 AI 分析(不调用 Dify API
- 不改变会话状态(非文本不影响 AI 对话状态)
- 不下载媒体文件,只存储企微回传的元数据
Args:
from_user_id: 发送消息的员工企微 UserID
content: 消息内容(非文本通常为空)
msg_type: 消息类型(image/voice/video/file/location
media_id: 企微媒体文件ID
extra_data: 扩展元数据
file_name: 文件名
file_size: 文件大小
Returns:
Conversation: 更新后的会话对象
"""
# 1. 查找或创建会话(复用现有逻辑)
conversation = await self._find_or_create_conversation(
from_user_id, content or f"[{msg_type}]"
)
# 2. 构建非文本消息的展示文本(存入 content 字段,用于前端展示)
display_text = self._get_non_text_display(msg_type, file_name, extra_data)
# 3. 生成自动回复文本
reply_text = self._get_non_text_reply(msg_type)
# 4. 创建员工消息记录(存储非文本消息元数据)
message = Message(
conversation_id=conversation.id,
sender_type="employee",
sender_id=from_user_id,
sender_name=conversation.employee_name or from_user_id,
content=display_text, # 展示用文本,如 "[图片消息]"
msg_type=msg_type,
media_id=media_id,
file_name=file_name,
file_size=file_size,
extra_data=extra_data,
is_read=False,
)
self.db.add(message)
# 5. 发送自动回复到企微
try:
await self.wecom_service.send_text_message(
user_id=from_user_id,
content=reply_text,
)
except Exception as e:
logger.error(f"发送非文本消息自动回复失败: {e}")
# 6. 创建自动回复消息记录
reply_message = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="AI智能助手",
content=reply_text,
msg_type="text",
is_read=False,
)
self.db.add(reply_message)
# 7. 更新会话(不改变状态,只更新时间戳和摘要)
conversation.last_message_at = datetime.now()
conversation.last_message_summary = display_text[:256]
conversation.updated_at = datetime.now()
self.db.add(conversation)
await self.db.flush()
logger.info(
f"非文本消息处理完成: conv_id={conversation.id}, "
f"msg_type={msg_type}, reply={reply_text[:30]}..."
)
# 8. 广播 WebSocket 事件
from app.services.ws_manager import manager as ws_manager
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(message.id),
"sender_type": "employee",
"sender_id": from_user_id,
"content": display_text,
"msg_type": msg_type,
"media_id": media_id,
"file_name": file_name,
"file_size": file_size,
"urgency_score": conversation.urgency_score,
"tags": conversation.tags,
"ai_replied": True,
}
})
except Exception as e:
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
return conversation
def _get_non_text_display(
self,
msg_type: str,
file_name: Optional[str] = None,
extra_data: Optional[Dict[str, Any]] = None,
) -> str:
"""根据消息类型生成展示文本。
Args:
msg_type: 消息类型(image/voice/video/file/location
file_name: 文件名(文件消息时使用)
extra_data: 扩展元数据
Returns:
str: 展示文本,如 "[图片消息]""[文件消息: report.pdf]"
"""
displays: dict[str, str] = {
"image": "[图片消息]",
"voice": "[语音消息]",
"video": "[视频消息]",
"file": f"[文件消息: {file_name}]" if file_name else "[文件消息]",
"location": "[位置消息]",
}
return displays.get(msg_type, f"[{msg_type}消息]")
def _get_non_text_reply(self, msg_type: str) -> str:
"""根据消息类型生成自动回复文本(发给员工)。
Args:
msg_type: 消息类型(image/voice/video/file/location
Returns:
str: 自动回复文本
"""
if msg_type == "image":
return (
"收到您的截图 📷\n"
"请补充文字描述您遇到的问题,以便更快为您处理。\n"
"例如:\n"
"• 这是什么软件的报错截图?\n"
"• 您在操作什么时出现的?\n"
"• 错误信息的具体内容是什么?"
)
type_names: dict[str, str] = {
"voice": "语音",
"video": "视频",
"file": "文件",
"location": "位置",
}
type_name = type_names.get(msg_type, msg_type)
return (
f"暂不支持{type_name}消息 😅\n"
"请用文字描述您的问题,我会尽快为您处理。"
)
async def _try_ai_reply(
self,
conversation: Conversation,
content: str,
from_user_id: str,
) -> bool:
"""尝试让 AI 回复员工消息。
重构说明:使用 AIHandler 统一处理打招呼检测、呼叫人工拦截、
AI 调用、命中判断、计数规则和转人工逻辑,确保与 H5 端行为完全一致。
流程:
1. 调用 AIHandler.handle_message() 获取统一结果
2. 根据结果类型:
- greeting/call_human → 发送引导话术到企微(不计数,不转人工)
- ai_hit → 发送 AI 回复到企微(计数+1,不转人工)
- ai_miss → 发送转人工提示到企微(不计数,转人工)
- ai_fallback → 发送降级模板到企微(不计数,不转人工)
3. 创建消息记录
4. 更新会话状态和计数
Args:
conversation: 当前会话
content: 员工消息内容
from_user_id: 员工企微 UserID
Returns:
bool: True=AI 已回复(含引导),False=需转人工或出错
"""
if not self.ai_handler:
logger.warning("AI 处理器不可用,跳过 AI 回复")
return False
# 调用 AIHandler 统一处理
result: AIReplyResult = await self.ai_handler.handle_message(
content=content,
dify_conversation_id=conversation.dify_conversation_id,
user_id=from_user_id,
)
# 更新 Dify 会话ID(多轮对话上下文)
if result.dify_conversation_id:
conversation.dify_conversation_id = result.dify_conversation_id
# 发送回复到企微(员工在企微中看到回复)
try:
await self.wecom_service.send_text_message(
user_id=from_user_id,
content=result.content,
)
except Exception as e:
logger.error(f"发送AI回复到企微失败: {e}")
# 企微发送失败不阻塞流程,坐席仍然能看
# 创建消息记录(根据类型选择 sender_type
if result.should_transfer:
# 转人工消息用系统消息类型
sender_type = "system"
sender_id = "system"
sender_name = "系统"
else:
# AI 回复/引导/降级均用 AI 消息类型
sender_type = "ai"
sender_id = "ai_bot"
sender_name = "AI智能助手"
ai_message = Message(
conversation_id=conversation.id,
sender_type=sender_type,
sender_id=sender_id,
sender_name=sender_name,
content=result.content,
msg_type="text",
is_read=False,
)
self.db.add(ai_message)
await self.db.flush()
# 更新 AI 实质性回复计数(仅命中时 +1)
if result.should_count:
conversation.ai_substantive_reply_count += 1
logger.info(
f"AI 命中并回复: conv_id={conversation.id}, "
f"ai_count={conversation.ai_substantive_reply_count}"
)
# 转人工处理
if result.should_transfer:
conversation.status = "queued"
logger.info(
f"AI 未命中,转人工: conv_id={conversation.id}"
)
return False
# 记录其他类型日志
if result.is_guidance:
logger.info(
f"AI 引导回复: conv_id={conversation.id}, "
f"type={result.reply_type}"
)
elif result.reply_type == "ai_fallback":
logger.info(
f"AI 降级模板回复: conv_id={conversation.id}"
)
return True
# --------------------------------------------------------------------------
# 查找或创建会话
# --------------------------------------------------------------------------
async def _find_or_create_conversation(
self, employee_id: str, content: str
) -> Conversation:
"""查找员工当前活跃的会话,如果不存在则创建新会话。
规则:
- 如果员工有 status 为 ai_handling 或 queued 或 serving 的会话,继续使用该会话
- 否则创建新会话,状态为 ai_handling(先让 AI 尝试回答)
Args:
employee_id: 员工企微 UserID
content: 消息内容(用于创建会话时设置摘要)
Returns:
Conversation: 找到的或新创建的会话对象
"""
# 查找当前活跃会话(ai_handling/queued/serving 状态)
stmt = select(Conversation).where(
Conversation.employee_id == employee_id,
Conversation.status.in_(["ai_handling", "queued", "serving"]),
).order_by(Conversation.created_at.desc())
result = await self.db.execute(stmt)
conversation = result.scalars().first()
if conversation:
logger.debug(f"找到活跃会话: conv_id={conversation.id}, status={conversation.status}")
return conversation
# 没有活跃会话,创建新会话
# 默认状态 ai_handling:先让 AI 尝试回答,AI 未命中再转 queued
conversation = Conversation(
employee_id=employee_id,
employee_name="", # 稍后通过 VIP 检测补充
department="",
position="",
level="",
status="ai_handling", # 先让 AI 尝试回答
is_vip=False,
is_pinned=False,
is_todo=False,
urgency_score=1,
tags={},
last_message_at=datetime.now(),
last_message_summary=content[:256],
)
self.db.add(conversation)
await self.db.flush() # 刷新以获取生成的 ID
logger.info(
f"创建新会话: conv_id={conversation.id}, "
f"employee_id={employee_id}, status=ai_handling"
)
return conversation
# --------------------------------------------------------------------------
# VIP 检测
# --------------------------------------------------------------------------
async def _check_vip(self, conversation: Conversation) -> None:
"""检测员工是否为 VIP 并更新会话信息。
通过企微通讯录 API 获取员工信息:
- 判断 VIP 规则:总监及以上 或 关键部门
- 补充员工姓名、部门、岗位、等级等信息
Args:
conversation: 会话对象(会被就地修改)
"""
# 已检测过 VIP 的会话不再重复检测
if conversation.is_vip:
return
try:
user_info = await self.wecom_service.get_user_info(
conversation.employee_id
)
# 补充员工信息
conversation.employee_name = user_info.get("name", "")
conversation.department = user_info.get("department", "") # 部门ID列表,JSON字符串
conversation.position = user_info.get("position", "")
conversation.level = user_info.get("position", "") # 企微无单独等级字段,暂用岗位
# VIP 规则:总监及以上 或 关键部门
# 第一步简单规则:职位中包含"总监"/"总经理"/"VP"/"CEO" 为 VIP
position_text = user_info.get("position", "")
vip_keywords = ["总监", "总经理", "VP", "CEO", "CIO", "CTO", "CFO", "COO"]
is_vip = any(kw in position_text for kw in vip_keywords)
conversation.is_vip = is_vip
if is_vip:
logger.info(
f"VIP标记: employee_id={conversation.employee_id}, "
f"position={position_text}"
)
# 缓存 VIP 结果到 Redis1 小时)
# 避免每次消息都调企微 API
# 这里暂不实现 Redis 缓存,后续优化
except Exception as e:
# VIP 检测失败不应阻塞消息路由
logger.warning(
f"VIP检测失败(不阻塞流程): employee_id={conversation.employee_id}, "
f"error={e}"
)
@@ -0,0 +1,350 @@
# =============================================================================
# 企微IT智能服务台 — 角色映射服务
# =============================================================================
# 说明:处理角色自动映射逻辑,支持以下来源:
# 1. 企微标签映射(wecom_tag
# 2. eHR 字段映射(ehr_position
# 3. 管理后台手动分配(manual)
# =============================================================================
import logging
import re
from datetime import datetime
from typing import Dict, List, Optional, Set
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.role import Role
from app.models.role_mapping_rule import RoleMappingRule
from app.models.user_role import UserRole
from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
def _mask_sensitive_data(value: str, visible_chars: int = 3) -> str:
"""脱敏处理敏感数据。
Args:
value: 原始值
visible_chars: 开头保留的字符数
Returns:
str: 脱敏后的值,如 "abc***def"
"""
if not value:
return ""
if len(value) <= visible_chars:
return "*" * len(value)
return f"{value[:visible_chars]}{'*' * (len(value) - visible_chars)}"
class RoleMappingService:
"""角色映射服务。
根据用户的企微标签、eHR岗位等信息,自动映射角色。
"""
def __init__(self, db: AsyncSession, wecom_service: Optional[WecomService] = None):
"""初始化角色映射服务。
Args:
db: 数据库会话
wecom_service: 企微API服务(可选,用于获取用户标签)
"""
self.db = db
self.wecom_service = wecom_service
async def get_user_roles(self, employee_id: str) -> List[str]:
"""获取用户的角色列表。
查询 user_roles 表,返回用户拥有的角色标识列表。
Args:
employee_id: 企微 UserID
Returns:
List[str]: 角色标识列表(如 ["user", "agent"]
"""
stmt = (
select(Role.name)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == employee_id)
.where(
# 过滤已过期的角色
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > datetime.now())
)
)
result = await self.db.execute(stmt)
roles = [row[0] for row in result.all()]
# 如果没有角色,添加默认的 user 角色
if not roles:
roles = ["user"]
return roles
async def sync_user_roles(
self,
employee_id: str,
wecom_tags: Optional[List[str]] = None,
ehr_position: Optional[str] = None,
) -> List[str]:
"""同步用户角色。
根据企微标签和eHR岗位,自动分配或撤销角色。
Args:
employee_id: 企微 UserID
wecom_tags: 企微标签列表(可选)
ehr_position: eHR岗位(可选)
Returns:
List[str]: 同步后的角色列表
"""
# 1. 获取当前角色
current_roles = await self.get_user_roles(employee_id)
# 2. 获取映射规则
mapping_rules = await self._get_active_mapping_rules()
# 3. 根据规则确定应该拥有的角色
should_have_roles: Set[str] = {"user"} # 所有人都有 user 角色
for rule in mapping_rules:
if rule.source_type == "wecom_tag" and wecom_tags:
# 检查标签是否匹配
if rule.source_value in wecom_tags:
role_name = await self._get_role_name_by_id(rule.role_id)
if role_name:
should_have_roles.add(role_name)
elif rule.source_type == "ehr_position" and ehr_position:
# 检查岗位关键词是否匹配
if rule.source_value in ehr_position:
role_name = await self._get_role_name_by_id(rule.role_id)
if role_name:
should_have_roles.add(role_name)
# 4. 计算需要添加和删除的角色
current_set = set(current_roles)
to_add = should_have_roles - current_set
to_remove = current_set - should_have_roles - {"user"} # 不删除 user 角色
# 5. 添加新角色
for role_name in to_add:
await self._add_role(employee_id, role_name, source="tag")
# 6. 撤销不再需要的角色(仅撤销自动分配的)
for role_name in to_remove:
await self._remove_auto_role(employee_id, role_name)
# 7. 返回同步后的角色列表
return await self.get_user_roles(employee_id)
async def _get_active_mapping_rules(self) -> List[RoleMappingRule]:
"""获取所有启用的映射规则。
Returns:
List[RoleMappingRule]: 映射规则列表
"""
stmt = (
select(RoleMappingRule)
.where(RoleMappingRule.is_active == True)
.order_by(RoleMappingRule.priority.desc())
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def _get_role_name_by_id(self, role_id: str) -> Optional[str]:
"""根据角色ID获取角色名称。
Args:
role_id: 角色ID
Returns:
Optional[str]: 角色名称,如果不存在返回 None
"""
stmt = select(Role.name).where(Role.id == role_id)
result = await self.db.execute(stmt)
row = result.first()
return row[0] if row else None
async def _add_role(self, employee_id: str, role_name: str, source: str) -> None:
"""为用户添加角色。
Args:
employee_id: 企微 UserID
role_name: 角色标识
source: 角色来源(auto/tag/ehr/manual
"""
# 查询角色
stmt = select(Role).where(Role.name == role_name)
result = await self.db.execute(stmt)
role = result.scalars().first()
if not role:
logger.warning(f"角色 {role_name} 不存在,跳过添加")
return
# 检查是否已存在
existing_stmt = select(UserRole).where(
UserRole.employee_id == employee_id,
UserRole.role_id == role.id,
)
existing_result = await self.db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
logger.debug(f"用户 {_mask_sensitive_data(employee_id)} 已拥有角色 {role_name},跳过添加")
return
# 创建用户角色关联
user_role = UserRole(
employee_id=employee_id,
role_id=role.id,
source=source,
)
self.db.add(user_role)
await self.db.commit()
logger.info(f"为用户 {_mask_sensitive_data(employee_id)} 添加角色 {role_name}(来源:{source}")
async def _remove_auto_role(self, employee_id: str, role_name: str) -> None:
"""撤销用户的自动分配角色。
仅撤销 source 为 auto/tag/ehr 的角色,不撤销手动分配的角色。
Args:
employee_id: 企微 UserID
role_name: 角色标识
"""
# 查询角色
stmt = select(Role).where(Role.name == role_name)
result = await self.db.execute(stmt)
role = result.scalars().first()
if not role:
return
# 查询用户角色关联(仅自动分配的)
user_role_stmt = select(UserRole).where(
UserRole.employee_id == employee_id,
UserRole.role_id == role.id,
UserRole.source.in_(["auto", "tag", "ehr"]), # 仅自动分配的
)
user_role_result = await self.db.execute(user_role_stmt)
user_role = user_role_result.scalars().first()
if user_role:
await self.db.delete(user_role)
await self.db.commit()
logger.info(f"撤销用户 {_mask_sensitive_data(employee_id)} 的自动分配角色 {role_name}")
async def get_wecom_user_tags(self, user_id: str) -> List[str]:
"""获取用户的企微标签列表。
调用企微通讯录API获取用户的标签ID列表,然后查询标签名称。
Args:
user_id: 企微 UserID
Returns:
List[str]: 标签名称列表
"""
if not self.wecom_service:
logger.warning("WecomService 未初始化,无法获取企微标签")
return []
try:
# 获取用户信息(包含 tagids
user_info = await self.wecom_service.get_user_info(user_id)
tag_ids = user_info.get("tagids", [])
if not tag_ids:
return []
# 查询标签名称
tag_names = await self._get_tag_names_by_ids(tag_ids)
return tag_names
except Exception as e:
logger.error(f"获取用户企微标签失败: user_id={user_id}, error={e}")
return []
# 标签名称验证常量
MAX_TAG_NAME_LENGTH = 50 # 最大标签名称长度
TAG_NAME_FORBIDDEN_CHARS = "<>'\"&;\\|%$#@`" # 禁止的特殊字符
def _validate_tag_name(self, tag_name: str) -> bool:
"""验证标签名称是否安全。
Args:
tag_name: 标签名称
Returns:
bool: 是否有效
"""
# 检查长度
if not tag_name or len(tag_name) > self.MAX_TAG_NAME_LENGTH:
return False
# 检查禁止字符
for char in self.TAG_NAME_FORBIDDEN_CHARS:
if char in tag_name:
return False
return True
async def _get_tag_names_by_ids(self, tag_ids: List[int]) -> List[str]:
"""根据标签ID列表获取标签名称。
调用企微标签管理API获取标签名称,并进行安全验证。
Args:
tag_ids: 标签ID列表
Returns:
List[str]: 验证后的标签名称列表
"""
if not self.wecom_service:
return []
try:
access_token = await self.wecom_service.get_access_token()
import httpx
async with httpx.AsyncClient() as client:
# 获取标签列表(企微API
url = "https://qyapi.weixin.qq.com/cgi-bin/tag/list"
params = {"access_token": access_token}
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"获取标签列表失败: {result}")
return []
# 构建 tag_id -> tag_name 映射(带安全验证)
tag_map = {}
for tag in result.get("taglist", []):
tag_name = tag.get("tagname", "")
# 安全验证:过滤不安全的标签名称
if self._validate_tag_name(tag_name):
tag_map[tag["tagid"]] = tag_name
# 返回匹配的标签名称
valid_tag_names = [
tag_map[tag_id]
for tag_id in tag_ids
if tag_id in tag_map
]
# 记录获取到的标签数量(非敏感信息)
logger.debug(f"获取到 {len(valid_tag_names)} 个有效标签")
return valid_tag_names
except Exception as e:
logger.error(f"获取标签名称失败: {e}")
return []
+406
View File
@@ -0,0 +1,406 @@
# =============================================================================
# 企微IT智能服务台 — 紧急度评分 + 标记检测服务
# =============================================================================
# 说明:实现 PRD 中定义的紧急度评分公式和标记检测规则
# 1. 紧急度评分:基础分(关键词) + 情绪加成 + VIP加成 + 重复追问加成
# 2. 举手标记检测:关键词匹配
# 3. 需介入标记检测:追问轮次 > 阈值
# 4. 情绪标记检测:关键词规则
# 所有关键词配置存储在 system_configs 表中,可后台动态修改
# =============================================================================
import json
import logging
from typing import Dict, List, Optional
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.message import Message
from app.models.system_config import SystemConfig
logger = logging.getLogger(__name__)
class ScoringService:
"""紧急度评分与标记检测服务。
实现评分公式紧急度 = 基础分 + 情绪加成 + VIP加成 + 重复追问加成
评分范围 1-5映射1=, 2=, 3=, 4=紧急, 5=最高
所有关键词和阈值从 system_configs 表读取支持后台动态修改
"""
# 默认配置(当数据库未配置时使用)
DEFAULT_HAND_RAISE_KEYWORDS = [
"转人工", "人工", "人工服务", "真人", "客服",
"帮我转人工", "找人工", "要人工", "不要AI", "不要机器人",
]
DEFAULT_EMOTION_KEYWORDS = {
"angry": ["崩溃", "愤怒", "投诉", "差劲", "垃圾", "太差了", "受不了"],
"urgent": ["", "紧急", "马上", "立刻", "赶紧", "十万火急", "快点"],
"worried": ["担心", "害怕", "出错", "丢失", "完蛋", "糟糕"],
}
DEFAULT_INTERVENE_THRESHOLD = 3
DEFAULT_URGENCY_SCORES = {
"base_keyword": 1,
"emotion_bonus": 1,
"vip_bonus": 1,
"repeat_bonus": 1,
}
def __init__(self, db: AsyncSession):
"""初始化评分服务。
Args:
db: 异步数据库会话用于读取 system_configs 配置
"""
self.db = db
# 缓存配置(避免每次请求都查数据库)
self._config_cache: Dict[str, str] = {}
self._cache_loaded = False
# --------------------------------------------------------------------------
# 配置加载
# --------------------------------------------------------------------------
async def _load_configs(self) -> None:
"""从 system_configs 表加载所有配置项到内存缓存。"""
if self._cache_loaded:
return
stmt = select(SystemConfig)
result = await self.db.execute(stmt)
configs = result.scalars().all()
for config in configs:
self._config_cache[config.config_key] = config.config_value
self._cache_loaded = True
logger.debug(f"评分配置加载完成: {len(self._config_cache)}")
async def _get_config(self, key: str, default: str = "") -> str:
"""获取配置值。
Args:
key: 配置键
default: 默认值
Returns:
str: 配置值字符串
"""
await self._load_configs()
return self._config_cache.get(key, default)
async def _get_json_config(self, key: str, default: List[str]) -> List[str]:
"""获取 JSON 格式的配置值(解析为列表)。
Args:
key: 配置键
default: 默认值列表
Returns:
List[str]: 解析后的字符串列表
"""
value = await self._get_config(key)
if not value:
return default
try:
return json.loads(value)
except json.JSONDecodeError:
logger.warning(f"配置解析失败: key={key}, value={value}")
return default
# --------------------------------------------------------------------------
# 举手标记检测
# --------------------------------------------------------------------------
def detect_hand_raise(self, content: str) -> bool:
"""检测消息中是否包含举手关键词。
举手关键词如"转人工""人工""找真人"
命中任意一个关键词即触发举手标记
关键词配置system_configs hand_raise_keywords
Args:
content: 消息内容
Returns:
bool: 是否触发举手标记
"""
# 由于 _get_json_config 是异步方法,这里使用默认关键词
# 在 calculate_urgency 中统一异步加载配置
keywords = self.DEFAULT_HAND_RAISE_KEYWORDS
content_lower = content.lower()
for keyword in keywords:
if keyword.lower() in content_lower:
logger.debug(f"举手关键词命中: keyword={keyword}")
return True
return False
async def detect_hand_raise_async(self, content: str) -> bool:
"""异步版本的举手标记检测(从数据库读取配置)。
Args:
content: 消息内容
Returns:
bool: 是否触发举手标记
"""
keywords = await self._get_json_config(
"hand_raise_keywords", self.DEFAULT_HAND_RAISE_KEYWORDS
)
content_lower = content.lower()
for keyword in keywords:
if keyword.lower() in content_lower:
logger.debug(f"举手关键词命中: keyword={keyword}")
return True
return False
# --------------------------------------------------------------------------
# 情绪标记检测
# --------------------------------------------------------------------------
def detect_emotion(self, content: str) -> str:
"""检测消息中的情绪标记。
情绪分类
- angry: 愤怒"崩溃""愤怒""投诉"
- urgent: 紧急"""紧急""马上"
- worried: 担忧"担心""害怕""出错"
- neutral: 正常无情绪关键词命中
检测优先级angry > urgent > worried因为愤怒最严重
关键词配置
- system_configs emotion_keywords_angry
- system_configs emotion_keywords_urgent
- system_configs emotion_keywords_worried
Args:
content: 消息内容
Returns:
str: 情绪类型angry/urgent/worried/neutral
"""
content_lower = content.lower()
# 按优先级检测:angry > urgent > worried
for emotion_type, keywords in self.DEFAULT_EMOTION_KEYWORDS.items():
for keyword in keywords:
if keyword.lower() in content_lower:
logger.debug(f"情绪关键词命中: emotion={emotion_type}, keyword={keyword}")
return emotion_type
return "neutral"
async def detect_emotion_async(self, content: str) -> str:
"""异步版本的情绪标记检测(从数据库读取配置)。
Args:
content: 消息内容
Returns:
str: 情绪类型
"""
content_lower = content.lower()
# 按优先级检测
emotion_config_keys = {
"angry": "emotion_keywords_angry",
"urgent": "emotion_keywords_urgent",
"worried": "emotion_keywords_worried",
}
for emotion_type, config_key in emotion_config_keys.items():
keywords = await self._get_json_config(
config_key, self.DEFAULT_EMOTION_KEYWORDS.get(emotion_type, [])
)
for keyword in keywords:
if keyword.lower() in content_lower:
return emotion_type
return "neutral"
# --------------------------------------------------------------------------
# 获取触发的情绪关键词列表
# --------------------------------------------------------------------------
def get_emotion_keywords(self, content: str, emotion: str) -> List[str]:
"""获取消息中触发了情绪标记的关键词列表。
用于在会话标签中记录具体触发了哪些关键词
Args:
content: 消息内容
emotion: 情绪类型
Returns:
List[str]: 触发的关键词列表
"""
content_lower = content.lower()
keywords = self.DEFAULT_EMOTION_KEYWORDS.get(emotion, [])
matched = [kw for kw in keywords if kw in content_lower]
return matched
# --------------------------------------------------------------------------
# 需介入标记检测
# --------------------------------------------------------------------------
async def detect_need_intervene(
self, conversation_id: UUID, db: AsyncSession
) -> bool:
"""检测会话是否需要介入(员工追问超过阈值轮次)。
检测逻辑
1. 统计该会话中员工连续发送的消息数中间无坐席回复
2. 如果连续追问轮次 > 阈值默认3触发需介入标记
阈值配置system_configs intervene_round_threshold
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
bool: 是否需要介入
"""
# 获取阈值
threshold_str = await self._get_config(
"intervene_round_threshold", str(self.DEFAULT_INTERVENE_THRESHOLD)
)
try:
threshold = int(threshold_str)
except ValueError:
threshold = self.DEFAULT_INTERVENE_THRESHOLD
# 查询该会话最近的员工消息数
# 简化逻辑:查询会话中员工发送的总消息数
stmt = select(func.count(Message.id)).where(
Message.conversation_id == conversation_id,
Message.sender_type == "employee",
)
result = await db.execute(stmt)
employee_msg_count = result.scalar() or 0
# 如果员工消息数 > 阈值,触发需介入
# 注意:这里是累计消息数,不是连续追问数
# 更精确的实现应该检查最后 N 条消息是否都是员工发的
is_need = employee_msg_count > threshold
if is_need:
logger.debug(
f"需介入检测: conv_id={conversation_id}, "
f"employee_msgs={employee_msg_count}, threshold={threshold}"
)
return is_need
# --------------------------------------------------------------------------
# 紧急度评分
# --------------------------------------------------------------------------
async def calculate_urgency(
self,
content: str = "",
tags: Optional[Dict] = None,
is_vip: bool = False,
) -> int:
"""计算会话紧急度评分。
公式紧急度 = 基础分(关键词) + 情绪加成 + VIP加成 + 重复追问加成
评分范围 1-5最终结果 clamp [1, 5]
各项说明
- 基础分消息中是否包含举手/情绪关键词命中则加分
- 情绪加成有情绪标记非neutral则加分
- VIP加成VIP 员工加分
- 重复追问加成追问轮次超过阈值则加分
分值配置
- urgency_base_keyword_score: 关键词基础加分默认1
- urgency_emotion_bonus: 情绪加成分默认1
- urgency_vip_bonus: VIP加成分默认1
- urgency_repeat_bonus: 重复追问加成分默认1
Args:
content: 消息内容
tags: 会话标签字典
is_vip: 是否 VIP 员工
Returns:
int: 紧急度评分1-5
"""
if tags is None:
tags = {}
# 从配置读取各项分值
base_keyword_score = int(
await self._get_config(
"urgency_base_keyword_score",
str(self.DEFAULT_URGENCY_SCORES["base_keyword"]),
)
)
emotion_bonus = int(
await self._get_config(
"urgency_emotion_bonus",
str(self.DEFAULT_URGENCY_SCORES["emotion_bonus"]),
)
)
vip_bonus = int(
await self._get_config(
"urgency_vip_bonus",
str(self.DEFAULT_URGENCY_SCORES["vip_bonus"]),
)
)
repeat_bonus = int(
await self._get_config(
"urgency_repeat_bonus",
str(self.DEFAULT_URGENCY_SCORES["repeat_bonus"]),
)
)
# 计算基础分:举手或情绪关键词命中则加分
score = 1 # 起始分
if tags.get("hand_raise", False) or tags.get("emotion", "neutral") != "neutral":
score += base_keyword_score
# 情绪加成:有情绪标记(非neutral)则加分
if tags.get("emotion", "neutral") != "neutral":
score += emotion_bonus
# VIP 加成
if is_vip:
score += vip_bonus
# 重复追问加成:追问轮次超过阈值则加分
intervene_threshold = int(
await self._get_config(
"intervene_round_threshold",
str(self.DEFAULT_INTERVENE_THRESHOLD),
)
)
repeat_count = tags.get("repeat_count", 0)
if repeat_count > intervene_threshold:
score += repeat_bonus
# Clamp 到 [1, 5]
score = max(1, min(5, score))
logger.debug(
f"紧急度评分: base={base_keyword_score}, emotion={emotion_bonus}, "
f"vip={vip_bonus}, repeat={repeat_bonus}, total={score}"
)
return score
# --------------------------------------------------------------------------
# 重置配置缓存(后台修改配置后调用)
# --------------------------------------------------------------------------
def reset_cache(self) -> None:
"""重置配置缓存。
当管理员在后台修改 system_configs 表后调用
下次请求时会重新从数据库加载配置
"""
self._config_cache = {}
self._cache_loaded = False
logger.info("评分配置缓存已重置")
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
# =============================================================================
# 企微IT智能服务台 — 统一 Token 服务
# =============================================================================
# 说明:统一 Token 管理,支持以下功能:
# 1. 创建统一格式的 Token(包含角色信息)
# 2. 验证 Token 并获取用户信息
# 3. 切换当前角色
# 4. 兼容旧格式 Tokenemployee:token 和 agent:token
# =============================================================================
import json
import logging
import secrets
from datetime import datetime
from typing import Dict, List, Optional
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
# Token TTL8小时)
TOKEN_TTL_SECONDS = 8 * 60 * 60
# 统一 Token Key 前缀
UNIFIED_TOKEN_PREFIX = "user:token:"
# 旧格式 Token Key 前缀(兼容)
EMPLOYEE_TOKEN_PREFIX = "employee:token:"
AGENT_TOKEN_PREFIX = "agent:token:"
class TokenService:
"""统一 Token 服务。
管理用户 Token 的创建验证角色切换等操作
"""
def __init__(self, redis_client: aioredis.Redis):
"""初始化 Token 服务。
Args:
redis_client: Redis 异步客户端
"""
self.redis = redis_client
async def create_token(
self,
employee_id: str,
name: str,
roles: List[str],
department: Optional[str] = None,
avatar: Optional[str] = None,
login_source: str = "portal",
) -> str:
"""创建统一格式的 Token。
Args:
employee_id: 企微 UserID
name: 用户姓名
roles: 角色列表 ["user", "agent"]
department: 部门可选
avatar: 头像URL可选
login_source: 登录来源portal/agent/h5
Returns:
str: Token 字符串
"""
# 生成 Token
token = secrets.token_urlsafe(32)
# 构建 Token 数据
token_data = {
"employee_id": employee_id,
"name": name,
"department": department or "",
"avatar": avatar or "",
"roles": roles,
"current_role": self._get_default_role(roles),
"login_source": login_source,
"created_at": datetime.now().isoformat(),
"last_active": datetime.now().isoformat(),
}
# 存入 Redis(统一格式)
await self.redis.setex(
f"{UNIFIED_TOKEN_PREFIX}{token}",
TOKEN_TTL_SECONDS,
json.dumps(token_data, ensure_ascii=False),
)
# 同时存入旧格式(兼容性)
if login_source == "agent":
await self.redis.setex(
f"{AGENT_TOKEN_PREFIX}{token}",
TOKEN_TTL_SECONDS,
employee_id,
)
else:
await self.redis.setex(
f"{EMPLOYEE_TOKEN_PREFIX}{token}",
TOKEN_TTL_SECONDS,
employee_id,
)
logger.info(f"创建 Token: employee_id={employee_id}, roles={roles}, source={login_source}")
return token
async def get_user_info(self, token: str) -> Optional[Dict]:
"""获取 Token 对应的用户信息。
支持新旧两种 Token 格式
Args:
token: Token 字符串
Returns:
Optional[Dict]: 用户信息字典如果 Token 无效返回 None
"""
# 1. 尝试统一格式
data = await self.redis.get(f"{UNIFIED_TOKEN_PREFIX}{token}")
if data:
try:
user_info = json.loads(data)
# 更新最后活跃时间
user_info["last_active"] = datetime.now().isoformat()
await self.redis.setex(
f"{UNIFIED_TOKEN_PREFIX}{token}",
TOKEN_TTL_SECONDS,
json.dumps(user_info, ensure_ascii=False),
)
return user_info
except json.JSONDecodeError:
logger.error(f"Token 数据解析失败: {token[:10]}...")
return None
# 2. 尝试旧格式(employee:token
employee_id = await self.redis.get(f"{EMPLOYEE_TOKEN_PREFIX}{token}")
if employee_id:
employee_id = employee_id.decode("utf-8") if isinstance(employee_id, bytes) else employee_id
# 获取员工信息缓存
info_data = await self.redis.get(f"employee:info:{employee_id}")
if info_data:
try:
info = json.loads(info_data)
return {
"employee_id": employee_id,
"name": info.get("employee_name", ""),
"department": info.get("department", ""),
"avatar": info.get("avatar", ""),
"roles": ["user"],
"current_role": "user",
"login_source": "h5",
"created_at": datetime.now().isoformat(),
"last_active": datetime.now().isoformat(),
}
except json.JSONDecodeError:
pass
# 降级:只有 employee_id
return {
"employee_id": employee_id,
"name": "",
"department": "",
"avatar": "",
"roles": ["user"],
"current_role": "user",
"login_source": "h5",
"created_at": datetime.now().isoformat(),
"last_active": datetime.now().isoformat(),
}
# 3. 尝试旧格式(agent:token
agent_id = await self.redis.get(f"{AGENT_TOKEN_PREFIX}{token}")
if agent_id:
agent_id = agent_id.decode("utf-8") if isinstance(agent_id, bytes) else agent_id
return {
"employee_id": agent_id,
"name": "",
"department": "",
"avatar": "",
"roles": ["user", "agent"],
"current_role": "agent",
"login_source": "agent",
"created_at": datetime.now().isoformat(),
"last_active": datetime.now().isoformat(),
}
return None
async def switch_role(self, token: str, new_role: str) -> bool:
"""切换当前角色。
Args:
token: Token 字符串
new_role: 目标角色标识
Returns:
bool: 是否切换成功
"""
# 获取当前用户信息
user_info = await self.get_user_info(token)
if not user_info:
return False
# 验证用户是否有目标角色
if new_role not in user_info.get("roles", []):
logger.warning(f"用户 {user_info['employee_id']} 没有 {new_role} 角色")
return False
# 更新当前角色
user_info["current_role"] = new_role
user_info["last_active"] = datetime.now().isoformat()
# 保存到 Redis(统一格式)
await self.redis.setex(
f"{UNIFIED_TOKEN_PREFIX}{token}",
TOKEN_TTL_SECONDS,
json.dumps(user_info, ensure_ascii=False),
)
# 同时更新旧格式(如果存在)
# 注意:旧格式只存储 employee_id,不需要更新
logger.info(f"用户 {user_info['employee_id']} 切换角色到 {new_role}")
return True
async def invalidate_token(self, token: str) -> None:
"""使 Token 失效。
删除统一格式和旧格式的 Token
Args:
token: Token 字符串
"""
# 删除统一格式
await self.redis.delete(f"{UNIFIED_TOKEN_PREFIX}{token}")
# 删除旧格式
await self.redis.delete(f"{EMPLOYEE_TOKEN_PREFIX}{token}")
await self.redis.delete(f"{AGENT_TOKEN_PREFIX}{token}")
logger.info(f"Token 已失效: {token[:10]}...")
def _get_default_role(self, roles: List[str]) -> str:
"""获取默认角色。
优先级admin > agent > user
Args:
roles: 角色列表
Returns:
str: 默认角色标识
"""
if "admin" in roles:
return "admin"
elif "agent" in roles:
return "agent"
else:
return "user"
+573
View File
@@ -0,0 +1,573 @@
# =============================================================================
# 企微IT智能服务台 — 企微 API 封装服务
# =============================================================================
# 说明:封装所有与企微服务器的交互逻辑,包括:
# 1. access_token 管理(Redis 缓存 + 自动刷新)
# 2. 发送消息(文本/图片/文件)
# 3. 获取员工信息(通讯录 API)
# 4. 上传临时素材
# 5. OAuth2 授权换算用户身份
# =============================================================================
import json
import logging
from typing import Any, Dict, List, Optional
import httpx
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
class WecomService:
"""企微 API 调用服务。
封装所有与企微服务器的 HTTP 交互提供异步方法
access_token 通过 Redis 缓存管理避免频繁调用获取接口
Attributes:
redis: Redis 异步客户端用于缓存 access_token
client: httpx 异步 HTTP 客户端
"""
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
"""初始化企微服务。
Args:
redis_client: Redis 异步客户端实例可为 None本地开发时 Redis 不可用
"""
self.redis = redis_client
# 创建 httpx 异步客户端
# timeout: 连接超时5秒,读取超时10秒
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=5.0)
)
# 内存缓存(Redis 不可用时的降级方案)
self._token_cache: Optional[str] = None
# --------------------------------------------------------------------------
# access_token 管理
# --------------------------------------------------------------------------
async def get_access_token(self) -> str:
"""获取企微 access_token。
优先从 Redis 缓存获取如果缓存不存在或即将过期则重新获取
access_token 有效期 7200 缓存 TTL 设为 6900 提前 300 秒刷新
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET
Returns:
str: access_token 字符串
Raises:
Exception: 获取 access_token 失败
"""
# Redis 缓存 key
cache_key = "wecom:access_token"
# 1. 尝试从 Redis 缓存获取
if self.redis:
try:
cached_token = await self.redis.get(cache_key)
if cached_token:
logger.debug("从缓存获取 access_token")
return cached_token.decode("utf-8")
except Exception as e:
logger.warning(f"Redis 读取失败(降级): {e}")
# 1b. 尝试从内存缓存获取
if self._token_cache:
logger.debug("从内存缓存获取 access_token")
return self._token_cache
# 2. 缓存未命中,调用企微 API 获取
logger.info("缓存未命中,调用企微API获取 access_token")
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": settings.wecom_corp_id,
"corpsecret": settings.wecom_secret,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
# 检查企微API返回码
if result.get("errcode") != 0:
error_msg = result.get("errmsg", "未知错误")
logger.error(f"获取 access_token 失败: errcode={result.get('errcode')}, errmsg={error_msg}")
raise Exception(f"企微API错误: {error_msg}")
access_token = result["access_token"]
expires_in = result.get("expires_in", 7200)
# 3. 缓存到 RedisTTL = 有效期 - 300秒(提前刷新)
buffer_seconds = 300
cache_ttl = max(expires_in - buffer_seconds, 60) # 至少缓存 60 秒
if self.redis:
try:
await self.redis.setex(cache_key, cache_ttl, access_token)
except Exception as e:
logger.warning(f"Redis 写入失败(降级): {e}")
# 3b. 同时缓存到内存
self._token_cache = access_token
logger.info(f"access_token 获取成功,缓存 TTL={cache_ttl}")
return access_token
except httpx.HTTPError as e:
logger.error(f"获取 access_token 网络错误: {e}")
raise Exception(f"企微API网络错误: {e}") from e
# --------------------------------------------------------------------------
# 发送文本消息
# --------------------------------------------------------------------------
async def send_text_message(
self, user_id: str, content: str
) -> Dict[str, Any]:
"""向员工发送文本消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "text",
"agentid": 1000002,
"text": {"content": "消息内容"}
}
Args:
user_id: 员工的企微 UserID
content: 消息内容纯文本
Returns:
Dict[str, Any]: 企微API返回结果
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": user_id,
"msgtype": "text",
"agentid": int(settings.wecom_agent_id),
"text": {"content": content},
}
try:
response = await self.client.post(url, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(
f"发送文本消息失败: user_id={user_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
else:
logger.info(f"发送文本消息成功: user_id={user_id}")
return result
except httpx.HTTPError as e:
logger.error(f"发送文本消息网络错误: user_id={user_id}, error={e}")
raise Exception(f"发送消息网络错误: {e}") from e
# --------------------------------------------------------------------------
# 发送卡片消息
# --------------------------------------------------------------------------
async def send_card_message(
self,
user_id: str,
title: str,
description: str,
url: str = "",
btntxt: str = "详情",
) -> Dict[str, Any]:
"""向员工发送文本卡片消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "textcard",
"agentid": 1000002,
"textcard": {
"title": "标题",
"description": "描述",
"url": "链接",
"btntxt": "按钮文字"
}
}
Args:
user_id: 员工的企微 UserID
title: 卡片标题
description: 卡片描述
url: 卡片点击跳转链接
btntxt: 按钮文字默认"详情"
Returns:
Dict[str, Any]: 企微API返回结果
"""
access_token = await self.get_access_token()
url_api = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": user_id,
"msgtype": "textcard",
"agentid": int(settings.wecom_agent_id),
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": btntxt,
},
}
try:
response = await self.client.post(url_api, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(
f"发送卡片消息失败: user_id={user_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
else:
logger.info(f"发送卡片消息成功: user_id={user_id}")
return result
except httpx.HTTPError as e:
logger.error(f"发送卡片消息网络错误: user_id={user_id}, error={e}")
raise Exception(f"发送消息网络错误: {e}") from e
# --------------------------------------------------------------------------
# 发送图片消息
# --------------------------------------------------------------------------
async def send_image_message(
self, user_id: str, media_id: str
) -> Dict[str, Any]:
"""向员工发送图片消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "image",
"agentid": 1000002,
"image": {"media_id": "MEDIA_ID"}
}
注意发送图片前需要先通过 upload_temp_media 上传图片获取 media_id
Args:
user_id: 员工的企微 UserID
media_id: 图片媒体ID通过上传临时素材获取
Returns:
Dict[str, Any]: 企微API返回结果
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": user_id,
"msgtype": "image",
"agentid": int(settings.wecom_agent_id),
"image": {"media_id": media_id},
}
try:
response = await self.client.post(url, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(
f"发送图片消息失败: user_id={user_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
else:
logger.info(f"发送图片消息成功: user_id={user_id}")
return result
except httpx.HTTPError as e:
logger.error(f"发送图片消息网络错误: user_id={user_id}, error={e}")
raise Exception(f"发送消息网络错误: {e}") from e
# --------------------------------------------------------------------------
# 发送文件消息
# --------------------------------------------------------------------------
async def send_file_message(
self, user_id: str, media_id: str
) -> Dict[str, Any]:
"""向员工发送文件消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "file",
"agentid": 1000002,
"file": {"media_id": "MEDIA_ID"}
}
注意发送文件前需要先通过 upload_temp_media 上传文件获取 media_id
Args:
user_id: 员工的企微 UserID
media_id: 文件媒体ID通过上传临时素材获取
Returns:
Dict[str, Any]: 企微API返回结果
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": user_id,
"msgtype": "file",
"agentid": int(settings.wecom_agent_id),
"file": {"media_id": media_id},
}
try:
response = await self.client.post(url, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(
f"发送文件消息失败: user_id={user_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
else:
logger.info(f"发送文件消息成功: user_id={user_id}")
return result
except httpx.HTTPError as e:
logger.error(f"发送文件消息网络错误: user_id={user_id}, error={e}")
raise Exception(f"发送消息网络错误: {e}") from e
# --------------------------------------------------------------------------
# 获取员工通讯录信息
# --------------------------------------------------------------------------
async def get_user_info(self, user_id: str) -> Dict[str, Any]:
"""获取员工通讯录详细信息(用于 VIP 判断)。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=TOKEN&userid=USERID
返回数据包含:
- userid: 员工UserID
- name: 员工姓名
- department: 部门ID列表
- position: 岗位
- mobile: 手机号
- email: 邮箱
- status: 激活状态
需要企微通讯录只读权限
Args:
user_id: 员工的企微 UserID
Returns:
Dict[str, Any]: 员工信息字典
Raises:
Exception: 获取失败
"""
access_token = await self.get_access_token()
url = "https://qyapi.weixin.qq.com/cgi-bin/user/get"
params = {
"access_token": access_token,
"userid": user_id,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"获取员工信息失败: user_id={user_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"获取员工信息失败: {result.get('errmsg')}")
logger.info(f"获取员工信息成功: user_id={user_id}, name={result.get('name', '')}")
return result
except httpx.HTTPError as e:
logger.error(f"获取员工信息网络错误: user_id={user_id}, error={e}")
raise Exception(f"获取员工信息网络错误: {e}") from e
# --------------------------------------------------------------------------
# 获取部门成员列表
# --------------------------------------------------------------------------
async def get_department_members(
self, department_id: int = 1, fetch_child: int = 1
) -> List[Dict[str, Any]]:
"""获取部门成员列表。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/user/list?access_token=TOKEN&department_id=ID&fetch_child=1
Args:
department_id: 部门ID默认1为根部门
fetch_child: 是否递归获取子部门1=, 0=
Returns:
List[Dict[str, Any]]: 部门成员列表
Raises:
Exception: 获取失败
"""
access_token = await self.get_access_token()
url = "https://qyapi.weixin.qq.com/cgi-bin/user/list"
params = {
"access_token": access_token,
"department_id": department_id,
"fetch_child": fetch_child,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"获取部门成员失败: dept_id={department_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"获取部门成员失败: {result.get('errmsg')}")
userlist = result.get("userlist", [])
logger.info(f"获取部门成员成功: dept_id={department_id}, count={len(userlist)}")
return userlist
except httpx.HTTPError as e:
logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}")
raise Exception(f"获取部门成员网络错误: {e}") from e
# --------------------------------------------------------------------------
# 上传临时素材
# --------------------------------------------------------------------------
async def upload_temp_media(
self, media_type: str, file_data: bytes, filename: str = "upload"
) -> str:
"""上传临时素材(图片/文件/语音),获取 media_id。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=TOKEN&type=TYPE
临时素材有效期 3 适用于发送图片/文件消息
Args:
media_type: 媒体类型image/file/voice
file_data: 文件二进制数据
filename: 文件名
Returns:
str: media_id用于发送图片/文件消息时引用
Raises:
Exception: 上传失败
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type={media_type}"
try:
# 使用 multipart 上传文件
files = {"media": (filename, file_data)}
response = await self.client.post(url, files=files)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"上传临时素材失败: type={media_type}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"上传临时素材失败: {result.get('errmsg')}")
media_id = result.get("media_id", "")
logger.info(f"上传临时素材成功: type={media_type}, media_id={media_id}")
return media_id
except httpx.HTTPError as e:
logger.error(f"上传临时素材网络错误: type={media_type}, error={e}")
raise Exception(f"上传临时素材网络错误: {e}") from e
# --------------------------------------------------------------------------
# OAuth2 授权换算用户身份
# --------------------------------------------------------------------------
async def get_oauth_user_info(self, code: str) -> Dict[str, str]:
"""通过 OAuth2 授权码换取员工身份信息。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=TOKEN&code=CODE
H5 页面通过企微 OAuth2 静默授权获取 code后端用 code 换取员工 UserID
适用于 H5 用户端身份识别
Args:
code: 企微 OAuth2 授权码
Returns:
Dict[str, str]: 包含 userid user_ticket 的字典
Raises:
Exception: 换取失败
"""
access_token = await self.get_access_token()
url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"
params = {
"access_token": access_token,
"code": code,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"OAuth2换取用户身份失败: code={code}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"OAuth2换取用户身份失败: {result.get('errmsg')}")
user_id = result.get("userid", "")
logger.info(f"OAuth2换取用户身份成功: userid={user_id}")
return {
"userid": user_id,
"user_ticket": result.get("user_ticket", ""),
}
except httpx.HTTPError as e:
logger.error(f"OAuth2换取用户身份网络错误: code={code}, error={e}")
raise Exception(f"OAuth2换取用户身份网络错误: {e}") from e
# --------------------------------------------------------------------------
# 关闭客户端
# --------------------------------------------------------------------------
async def close(self) -> None:
"""关闭 HTTP 客户端连接池。
应用关闭时调用释放资源
"""
await self.client.aclose()
logger.info("WecomService HTTP 客户端已关闭")
+445
View File
@@ -0,0 +1,445 @@
# =============================================================================
# 企微IT智能服务台 — AI Wingman 服务(坐席智能副驾驶)
# =============================================================================
# 说明:复用 Dify 基础设施,使用独立的 Wingman AgentAgent 2),
# 与员工端 AIAgent 1)共用知识库但 system prompt 不同。
#
# 核心能力:
# 1. 生成 AI 草稿回复 — 基于对话上下文为坐席生成专业回复
# 2. 生成会话自动摘要 — 结单时自动提取问题/原因/解决方案
# 3. 生成自动标签建议 — 基于对话内容建议标签分类
#
# 降级策略:Wingman Agent 不可用时返回友好错误信息,不抛异常
# =============================================================================
import json
import logging
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class WingmanService:
"""AI Wingman 服务 — 坐席智能副驾驶。
复用 Dify 基础设施使用独立的 Wingman AgentAgent 2
与员工端 AIAgent 1共用知识库但 system prompt 不同
三个核心方法使用不同的 system prompt
- 草稿生成生成坐席可采纳的专业回复
- 摘要生成提取结构化的会话摘要
- 标签建议建议标签分类和优先级
"""
# --------------------------------------------------------------------------
# System Prompt 定义
# --------------------------------------------------------------------------
_DRAFT_SYSTEM_PROMPT: str = (
"你是一个IT服务坐席助手,基于对话上下文为坐席生成专业、准确的回复草稿。"
"直接输出回复内容,不要解释。"
)
_SUMMARY_SYSTEM_PROMPT: str = (
"你是一个IT服务分析助手,基于完整对话生成结构化摘要,"
"包含:问题、原因、解决方案。以JSON格式输出。"
"输出格式:{\"problem\": \"问题描述\", \"cause\": \"原因\", \"solution\": \"解决方案\"}"
)
_TAGS_SYSTEM_PROMPT: str = (
"你是一个IT服务分类助手,基于对话内容建议标签分类。以JSON格式输出。"
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
)
def __init__(self):
"""初始化 Wingman 服务。
从配置读取 Wingman Agent API 地址和认证信息
独立于 AIService使用自己的 httpx 客户端
"""
# Wingman Agent 专用 API 端点
self.api_url = settings.dify_wingman_api_url
# Wingman Agent API Key
self.api_key = settings.dify_wingman_api_key
# 请求超时(秒)
self.timeout = settings.dify_wingman_timeout
# httpx 异步客户端(复用连接池)
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 httpx 异步客户端(懒加载)。
复用连接池避免每次请求都创建新连接
Returns:
httpx.AsyncClient: 异步 HTTP 客户端实例
"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
)
return self._client
async def close(self):
"""关闭 httpx 客户端,释放连接池资源。"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
logger.debug("WingmanService httpx client closed")
# --------------------------------------------------------------------------
# 核心方法 1:生成 AI 草稿回复
# --------------------------------------------------------------------------
async def generate_draft(
self,
conversation_id: str,
messages: List[Dict[str, Any]],
db: Any = None,
) -> Dict[str, Any]:
"""生成 AI 草稿回复。
传入当前会话的完整消息历史 Wingman Agent 基于上下文
生成坐席可以采纳的草稿回复
Args:
conversation_id: 会话ID
messages: 会话消息历史列表每条消息包含 sender_type/content
db: 数据库会话可选当前未使用
Returns:
Dict: {
"content": str, # 草稿内容
"confidence": float, # 置信度(0-1
"reasoning": str, # 生成推理说明
}
"""
# 构建对话上下文消息列表
context_messages = self._build_context_messages(
messages, self._DRAFT_SYSTEM_PROMPT
)
try:
result = await self._call_wingman_api(context_messages)
if result is None:
return {
"content": "",
"confidence": 0.0,
"reasoning": "Wingman 服务暂不可用",
}
reply_content = result
# 基于回复长度和内容质量估算置信度
confidence = self._estimate_confidence(reply_content)
return {
"content": reply_content,
"confidence": confidence,
"reasoning": f"基于最近 {len(messages)} 条对话上下文生成",
}
except Exception as e:
logger.error(f"Wingman 草稿生成失败: {e}")
return {
"content": "",
"confidence": 0.0,
"reasoning": f"AI 服务异常: {str(e)}",
}
# --------------------------------------------------------------------------
# 核心方法 2:生成会话自动摘要
# --------------------------------------------------------------------------
async def generate_summary(
self,
conversation_id: str,
messages: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""生成会话自动摘要。
基于完整对话生成结构化摘要包含问题原因解决方案
结单时自动调用
Args:
conversation_id: 会话ID
messages: 会话消息历史列表
Returns:
Dict: {
"problem": str, # 问题描述
"cause": str, # 原因分析
"solution": str, # 解决方案
}
"""
context_messages = self._build_context_messages(
messages, self._SUMMARY_SYSTEM_PROMPT
)
# 默认摘要(降级时使用)
default_summary = {
"problem": "无法自动生成摘要",
"cause": "",
"solution": "",
}
try:
result = await self._call_wingman_api(context_messages)
if result is None:
return default_summary
# 尝试解析 JSON 格式的摘要
parsed = self._parse_json_response(result, default_summary)
return {
"problem": parsed.get("problem", default_summary["problem"]),
"cause": parsed.get("cause", default_summary["cause"]),
"solution": parsed.get("solution", default_summary["solution"]),
}
except Exception as e:
logger.error(f"Wingman 摘要生成失败: {e}")
return default_summary
# --------------------------------------------------------------------------
# 核心方法 3:生成自动标签建议
# --------------------------------------------------------------------------
async def suggest_tags(
self,
conversation_id: str,
messages: List[Dict[str, Any]],
existing_tags: Dict[str, Any] = None,
) -> Dict[str, Any]:
"""生成自动标签建议。
基于对话内容建议标签分类包含标签列表分类和优先级
Args:
conversation_id: 会话ID
messages: 会话消息历史列表
existing_tags: 已有标签可选用于避免重复建议
Returns:
Dict: {
"suggested_tags": list[str], # 建议标签列表
"category": str, # 分类
"priority": str, # 优先级: low/medium/high
}
"""
context_messages = self._build_context_messages(
messages, self._TAGS_SYSTEM_PROMPT
)
# 默认标签建议(降级时使用)
default_tags = {
"suggested_tags": [],
"category": "",
"priority": "medium",
}
try:
result = await self._call_wingman_api(context_messages)
if result is None:
return default_tags
# 尝试解析 JSON 格式的标签建议
parsed = self._parse_json_response(result, default_tags)
return {
"suggested_tags": parsed.get("suggested_tags", []),
"category": parsed.get("category", ""),
"priority": parsed.get("priority", "medium"),
}
except Exception as e:
logger.error(f"Wingman 标签建议失败: {e}")
return default_tags
# --------------------------------------------------------------------------
# 内部方法
# --------------------------------------------------------------------------
def _build_context_messages(
self,
messages: List[Dict[str, Any]],
system_prompt: str,
) -> List[Dict[str, str]]:
"""构建发送给 Wingman Agent 的消息列表。
将数据库中的消息历史转换为 OpenAI Chat Completions 格式的
messages 列表包含 system prompt 和对话上下文
Args:
messages: 数据库消息列表
system_prompt: 当前场景的 system prompt
Returns:
List[Dict]: OpenAI 格式的消息列表
"""
# 构建上下文消息列表
context: List[Dict[str, str]] = [
{"role": "system", "content": system_prompt}
]
# 角色映射:数据库 sender_type → OpenAI role
role_map = {
"employee": "user", # 员工消息 → user
"agent": "assistant", # 坐席消息 → assistant
"ai": "assistant", # AI消息 → assistant
"system": "system", # 系统消息 → system
}
for msg in messages:
role = role_map.get(msg.get("sender_type", ""), "user")
content = msg.get("content", "")
if content:
# 跳过系统消息(已有 system prompt
if msg.get("sender_type") == "system":
continue
context.append({"role": role, "content": content})
return context
async def _call_wingman_api(
self,
context_messages: List[Dict[str, str]],
) -> Optional[str]:
"""调用 Wingman Agent API(非流式)。
Args:
context_messages: OpenAI 格式的消息列表
Returns:
Optional[str]: AI 回复内容失败时返回 None
"""
payload = {
"model": "Chat",
"messages": context_messages,
"stream": False,
"temperature": 0.3, # 适中的温度,保证准确性同时有一定灵活性
}
try:
client = await self._get_client()
logger.info(f"调用 Wingman API: messages_count={len(context_messages)}")
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
data = response.json()
# 解析 OpenAI 兼容格式的返回
choices = data.get("choices", [])
if not choices:
logger.warning("Wingman API 返回空 choices")
return None
reply_content = choices[0]["message"]["content"]
logger.info(f"Wingman API 返回: content_length={len(reply_content)}")
return reply_content
except httpx.TimeoutException:
logger.error("Wingman API 超时")
return None
except httpx.HTTPStatusError as e:
logger.error(f"Wingman API HTTP 错误: status={e.response.status_code}")
return None
except Exception as e:
logger.error(f"Wingman API 调用失败: {e}")
return None
def _parse_json_response(
self,
content: str,
default: Dict[str, Any],
) -> Dict[str, Any]:
"""解析 AI 返回的 JSON 内容。
Wingman Agent 可能返回带 markdown 代码块的 JSON
也可能返回纯 JSON此方法尝试多种解析方式
Args:
content: AI 返回的原始文本
default: 解析失败时的默认值
Returns:
Dict: 解析后的字典失败时返回默认值
"""
if not content:
return default
# 尝试 1:直接解析
try:
return json.loads(content)
except json.JSONDecodeError:
pass
# 尝试 2:提取 markdown 代码块中的 JSON
# AI 可能返回 ```json ... ``` 格式
import re
json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group(1).strip())
except json.JSONDecodeError:
pass
# 尝试 3:查找第一个 { 到最后一个 } 之间的内容
start = content.find('{')
end = content.rfind('}')
if start != -1 and end != -1 and end > start:
try:
return json.loads(content[start:end + 1])
except json.JSONDecodeError:
pass
logger.warning(f"Wingman JSON 解析失败,使用默认值: {content[:200]}")
return default
def _estimate_confidence(self, content: str) -> float:
"""估算 AI 草稿回复的置信度。
基于回复长度和内容特征估算一个粗略的置信度值
- 回复过短< 10 字符低置信度
- 回复包含不确定措辞降低置信度
- 回复长度适中内容具体高置信度
Args:
content: AI 回复内容
Returns:
float: 置信度0.0 - 1.0
"""
if not content or len(content.strip()) < 5:
return 0.2
confidence = 0.8 # 基础置信度
# 回复过短降低置信度
if len(content) < 10:
confidence -= 0.3
elif len(content) < 30:
confidence -= 0.1
# 包含不确定措辞降低置信度
uncertain_phrases = ["可能", "大概", "也许", "不确定", "建议您"]
for phrase in uncertain_phrases:
if phrase in content:
confidence -= 0.05
# 包含具体步骤或链接提高置信度
confident_phrases = ["步骤", "请按以下", "点击", "打开", "http"]
for phrase in confident_phrases:
if phrase in content:
confidence += 0.05
# 限制在 0.0 - 1.0 范围内
return max(0.0, min(1.0, confidence))

Some files were not shown because too many files have changed in this diff Show More