Files
wecom_it_smart_desk/docs/邀请功能-技术方案.md
T

15 KiB
Raw Blame History

邀请功能 — 技术方案

场景:坐席在处理会话时需要拉入其他员工/部门协助,通过邀请功能将新人加入同一会话。

与"摇人"的区别:摇人是坐席→坐席的协作(collaborating_agent_ids),邀请是坐席→任意员工/部门的协作(participants)。


一、方案决策记录

1.1 方案选型(2026-06-10 确认)

方案 核心思路 可行性 结论
方案一:一对一+邀请 在现有会话扩展参与者,企微应用消息通知 可行 备选
方案二:应用群聊 企微 appchat 创建群,群内沟通 应用无法接收群内消息 不可行
方案三:WebSocket+应用消息双通道 后端维护 participants,WebSocket 通信,企微消息仅通知 零新增基础设施 采纳

方案二不可行原因:企微 appchat 是「应用推送消息群」,群成员在群内发言不会回调给应用。应用只能单向推送消息到群,无法看到用户回复,坐席工作台无法获取群内对话。


二、数据模型改动

2.1 Conversation 模型新增字段

# backend/app/models/conversation.py

# 会话参与者列表(JSON 数组)
# 与 collaborating_agent_ids 的区别:
#   - collaborating_agent_ids:被邀请来协助的坐席ID(坐席间协作)
#   - participants:被邀请加入会话的员工/部门成员(跨端协作)
# 每个 participant 包含:userid, name, department, joined_at, role, invited_by
participants: Mapped[list] = mapped_column(
    JSON,
    nullable=False,
    default=list,
    comment="会话参与者列表",
)

2.2 participants 字段结构

[
  {
    "userid": "zhangsan",
    "name": "张三",
    "department": "技术部/网络组",
    "role": "invited",        // "invited"=被邀请人, "owner"=原始员工
    "invited_by": "agent_001", // 邀请人的坐席ID
    "joined_at": "2026-06-10T14:30:00Z",
    "history_shared": "last_10", // "all" / "last_10" / "none"
    "status": "active"         // "active" / "left"
  }
]

2.3 数据库迁移 SQL

-- 开发环境 SQLite / 生产环境 PostgreSQL 通用
ALTER TABLE conversations ADD COLUMN participants JSON NOT NULL DEFAULT '[]';

2.4 权限矩阵

                        原始员工    主责坐席    协作坐席    被邀请人
查看消息                  ✅          ✅          ✅          ✅
发送消息                  ✅          ✅          ✅          ✅
邀请他人                  ❌          ✅          ✅          ❌
结单                      ❌          ✅          ❌          ❌
转接                      ❌          ✅          ❌          ❌
退出会话                  关闭页面     ❌(主责不可) ✅(退出协作) ✅
移除参与者                 ❌          ✅          ❌          ❌

三、后端实现

3.1 新增 Schema

# backend/app/schemas/conversation.py

class ConversationInviteRequest(BaseModel):
    """邀请请求"""
    user_ids: list[str] = Field(..., description="被邀请人ID列表")
    department_ids: list[str] = Field(default_factory=list, description="部门ID列表(整部门邀请)")
    history_shared: str = Field("last_10", description="历史消息共享模式: all/last_10/none")

class ConversationInviteResponse(BaseModel):
    """邀请响应"""
    conversation_id: UUID
    invited_count: int = Field(..., description="成功邀请人数")
    failed: list[dict] = Field(default_factory=list, description="邀请失败的用户及原因")
    participants: list[dict] = Field(default_factory=list, description="更新后的参与者列表")


class ConversationLeaveRequest(BaseModel):
    """退出会话请求"""
    pass

3.2 ConversationResponse 扩展字段

class ConversationResponse(BaseModel):
    # ... 现有字段 ...

    # ----- 邀请功能扩展字段 -----
    participants: list[dict] = Field(default_factory=list, description="参与者列表")
    participant_count: int = Field(0, description="参与者人数")

3.3 新增 API 端点

# backend/app/api/conversations.py

# POST /api/conversations/{id}/invite
# 坐席邀请员工/部门加入会话
@router.post("/conversations/{conversation_id}/invite")
async def invite_participants(
    conversation_id: UUID,
    body: ConversationInviteRequest,
    db: AsyncSession = Depends(get_db),
    current_agent: Agent = Depends(get_current_agent),
):
    """
    邀请员工/部门加入会话。

    校验规则:
    1. 当前用户必须是主责坐席或协作坐席
    2. 会话状态必须为 serving
    3. 被邀请人不能已在 participants 中
    4. 被邀请人不能是主责坐席

    副作用:
    1. 更新 conversation.participants
    2. 企微应用消息通知被邀请人
    3. WebSocket 广播系统消息
    """


# POST /api/conversations/{id}/leave
# 被邀请人退出会话
@router.post("/conversations/{conversation_id}/leave")
async def leave_conversation(
    conversation_id: UUID,
    db: AsyncSession = Depends(get_db),
    current_user = Depends(get_current_user),  # 可以是坐席或H5用户
):
    """
    参与者退出会话。

    校验规则:
    1. 当前用户必须在 participants 中(role=invited
    2. 主责坐席不能退出
    3. 原始员工不能退出(关闭页面即视为离开)

    副作用:
    1. 更新 participant.status = "left"
    2. WebSocket 广播系统消息
    """


# DELETE /api/conversations/{id}/participants/{userid}
# 坐席移除参与者
@router.delete("/conversations/{conversation_id}/participants/{userid}")
async def remove_participant(
    conversation_id: UUID,
    userid: str,
    db: AsyncSession = Depends(get_db),
    current_agent: Agent = Depends(get_current_agent),
):
    """
    主责坐席移除会话参与者。

    校验规则:
    1. 当前用户必须是主责坐席
    2. 被移除人必须在 participants 中且 status=active

    副作用:
    1. 更新 participant.status = "left"
    2. WebSocket 广播系统消息
    """

3.4 企微通知卡片消息

邀请时发送企微 template_card 卡片消息:

# backend/app/services/wecom_service.py

async def send_invite_card(
    self,
    invitee_userid: str,
    inviter_name: str,
    employee_name: str,
    problem_summary: str,
    conversation_id: str,
) -> None:
    """发送邀请卡片消息给被邀请人"""

    card = {
        "msgtype": "template_card",
        "template_card": {
            "card_type": "button_interaction",
            "source": {
                "desc": "IT智能服务台"
            },
            "main_title": {
                "title": "🔔 会话邀请"
            },
            "emphasis_content": {
                "title": f"{inviter_name} 邀请你协助处理",
                "desc": f"员工:{employee_name}"
            },
            "sub_title_text": f"问题:{problem_summary[:50]}",
            "button_list": [
                {
                    "text": "加入会话",
                    "style": 1,  # 蓝色主按钮
                    "key": f"join_{conversation_id}"
                },
                {
                    "text": "稍后查看",
                    "style": 2,  # 灰色次按钮
                    "key": "later"
                }
            ]
        }
    }

3.5 WebSocket 事件定义

事件类型 推送范围 数据
participant_invited 所有在线坐席 + 会话内H5用户 { conversation_id, invited_by, participants: [{userid, name, role}] }
participant_joined 所有在线坐席 + 会话内H5用户 { conversation_id, userid, name }
participant_left 所有在线坐席 + 会话内H5用户 { conversation_id, userid, name, reason: "self_left"/"removed" }
participant_removed 所有在线坐席 + 被移除人 { conversation_id, userid, name, removed_by }

3.6 历史消息共享逻辑

# backend/app/services/session_service.py

async def get_shared_messages(
    self,
    conversation_id: UUID,
    history_shared: str,  # "all" / "last_10" / "none"
) -> list[dict]:
    """根据共享模式返回历史消息"""

    if history_shared == "none":
        return []

    messages = await self._get_conversation_messages(conversation_id)

    if history_shared == "last_10":
        # 取最近10条,优先包含人工消息
        return messages[-10:]

    # "all" — 返回全部
    return messages

四、前端实现

4.1 坐席工作台(Agent

4.1.1 API 层新增

// frontend-agent/src/api/conversation.ts

/** 邀请员工/部门加入会话 */
export function inviteParticipants(
  conversationId: string,
  data: { user_ids: string[]; department_ids?: string[]; history_shared?: string }
): Promise<ConversationInviteResponse>

/** 退出会话 */
export function leaveConversation(conversationId: string): Promise<void>

/** 移除参与者 */
export function removeParticipant(conversationId: string, userid: string): Promise<void>

4.1.2 邀请弹窗组件

┌──────────────────────────────────────────┐
│  邀请加入会话                              │
│                                          │
│  🔍 [搜索姓名/工号...]                    │
│                                          │
│  ┌── 组织架构 ──┐  ┌── 已选 (3人) ──────┐│
│  │ ▼ 技术部     │  │ × 张三 / 网络组    ││
│  │   ☑ 网络组   │  │ × 李四 / 运维组    ││
│  │   ○ 运维组   │  │ × 王五 / 安全组    ││
│  │ ▼ 行政部     │  └────────────────────┘│
│  │   ○ 前台     │                        │
│  └──────────────┘                        │
│                                          │
│  历史消息共享:                           │
│  ○ 全部  ● 最近10条  ○ 不共享             │
│                                          │
│       [取消]              [确认邀请]       │
└──────────────────────────────────────────┘

4.1.3 参与者面板(聊天区头部)

┌──────────────────────────────────────────────────┐
│  👤 张三 · 网络问题    [+ 邀请] [⋮]              │
│  👥 3人参与: 我(坐席) · 张三(员工) · 李四(受邀)  │  ← 可点击展开
└──────────────────────────────────────────────────┘

4.2 H5用户端(被邀请人视角)

4.2.1 加入会话流程

点击企微通知 → H5加载 → Mock登录/自动登录 → 加载会话 → 拉取历史 → WebSocket连接 → 可发送消息

4.2.2 参与者标识

┌──────────────────────────────────────────┐
│  IT支持会话                    [退出]     │
│  👥 参与者: 坐席(小宋) · 你 · 张三(网络组) │
├──────────────────────────────────────────┤
│                                          │
│  [系统] 李四(坐席)邀请你加入会话           │
│  [系统] 你已加入会话                       │
│  [坐席] 网络组的同事来看下这个VPN问题      │
│  [张三] 我看下,是零信任客户端连不上对吧   │
│                                          │
├──────────────────────────────────────────┤
│  [输入消息...]                    [发送]  │
└──────────────────────────────────────────┘

五、改动清单汇总

文件 改动类型 说明
backend/app/models/conversation.py 修改 +participants 字段
backend/app/schemas/conversation.py 修改 +邀请/退出/移除Schema + 响应扩展字段
backend/app/api/conversations.py 修改 +invite/leave/remove_participant 三个端点
backend/app/services/session_service.py 修改 +邀请/退出/历史共享逻辑
backend/app/services/wecom_service.py 修改 +send_invite_card 卡片消息
frontend-agent/src/api/conversation.ts 修改 +3个API函数
frontend-agent/src/stores/conversation.ts 修改 +participants相关计算属性和方法
frontend-agent/src/components/conversation/InviteDialog.vue 新建 邀请弹窗组件
frontend-agent/src/components/conversation/ParticipantBar.vue 新建 参与者面板组件
frontend-h5/src/components/chat/ParticipantList.vue 新建 H5参与者列表组件
frontend-h5/src/views/ChatView.vue 修改 +退出按钮 + 参与者展示
frontend-h5/src/api/conversation.ts 修改 +退出会话API
frontend-agent/src/composables/useWebSocket.ts 修改 +4个WS事件处理
frontend-h5/src/composables/useWebSocket.ts 修改 +4个WS事件处理
数据库迁移 SQL 新建 ALTER TABLE 加列

六、开发顺序

步骤 内容 依赖 预计工时
1 模型 + 迁移 SQL + participants字段 0.5天
2 Schema + SessionService邀请逻辑 1 1天
3 API端点(invite/leave/remove + 历史共享 2 1天
4 企微template_card消息发送 2 0.5天
5 后端测试 3 0.5天
6 坐席端 InviteDialog + ParticipantBar 组件 无(可并行) 1.5天
7 H5端 ChatView改动 + ParticipantList 无(可并行) 1天
8 WebSocket 事件处理(双端) 3 0.5天
9 端到端集成测试 全部 1天
合计 7-8天

七、设计决策

决策 理由
使用 participants JSON 数组而非关联表 参与者数量少(1-5人),JSON 查询足够;与 collaborating_agent_ids 设计一致
历史消息默认共享最近10条 避免被邀请人被大量无关历史淹没,同时保留足够上下文理解问题
被邀请人不能二次邀请 防止邀请链失控,只有坐席有权管理参与者
不使用企微 appchat 群聊 appchat 群内消息不会回调给应用,坐席无法获取群内对话
企微通知用 template_card 而非 text 卡片消息提供「加入会话」按钮,体验优于纯文本+手动复制链接
不设邀请人数硬上限 低频场景(1-3人常见),>10人弹窗提醒而非阻断