Files
x-server-mobile/docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md
T
root b90f901fd9 fix: 修复多公司多飞书应用消息卡片更新失败问题
- feishu_message 表新增 company_id 和 feishu_app_id 字段追踪消息发送者
- 修改 FeishuMessage::saveMessage() 支持保存公司和飞书应用信息
- 修改 FeishuNotificationService 使用原始发送者的飞书应用配置更新消息
- 新增 HttpClient::httpPatchJson() 方法支持 PATCH 请求
- 文档更新:PROJECT_ISSUES.md 添加问题31,APPROVAL_NOTIFICATION_ARCHITECTURE.md 添加多公司架构说明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:29:29 +08:00

30 KiB
Raw Blame History

审批与消息模块架构文档

版本: 1.0.0 更新时间: 2026-02-28 状态: 设计中


📋 概述

系统采用模块化设计,审批模块和消息模块是两个独立但可联合使用的核心模块。

设计原则

  1. 模块独立:各模块可独立部署和使用
  2. 松耦合:模块间通过接口通信,不直接依赖
  3. 可扩展:支持新增业务场景(如请假、采购、报销等)
  4. 统一协议:所有业务场景遵循相同的审批和消息协议

🏗️ 模块架构

┌─────────────────────────────────────────────────────────────┐
│                        业务场景层                            │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐  │
│  │ 出差审批 │  │ 请假审批 │  │ 采购审批 │  │ 费用报销 │  │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘  │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                      审批引擎层(ApprovalService)             │
│  - 流程管理 - 节点管理 - 订单管理 - 记录管理               │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                    消息服务层(FeishuNotificationService)    │
│  - 消息发送 - 卡片构建 - 消息更新 - 日志记录               │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                        飞书平台                               │
│  - 消息推送 - 卡片交互 - 回调处理                           │
└─────────────────────────────────────────────────────────────┘

🔧 审批模块(Approval Module)

核心模型

模型 说明 关键字段
ApprovalFlow 审批流程模板 business_type, name, status
ApprovalNode 审批节点 type, approvers, order
ApprovalOrder 审批订单实例 business_type, business_id, form_data, current_nodes, status
ApprovalRecord 审批操作记录 order_id, node_id, approver_id, action

节点类型

const TYPE_APPROVER = 'approver';       // 单人审批:1人通过即可
const TYPE_AND_APPROVE = 'and_approve'; // 会签:所有人通过
const TYPE_OR_APPROVE = 'or_approve';   // 或签:1人通过即可
const TYPE_NOTIFY = 'notify';           // 通知节点:无需审批

审批状态流转

draft(草稿)
    ↓ submit
pending(审批中)
    ↓
    ├─→ approved(已通过)
    ├─→ rejected(已拒绝)
    └─→ cancelled(已撤销)

核心方法

方法 说明
submit() 提交审批
approve() 审批通过
reject() 审批拒绝
cancel() 撤销申请
isNodeCompleted() 判断节点是否完成

扩展新业务场景

步骤:

  1. 创建业务模型(如 LeaveRequest)
  2. 定义流程类型(business_type = 'leave')
  3. 实现审批逻辑
    • 调用 ApprovalService::submit() 创建审批订单
    • 在 form_data 中存储业务数据
  4. 实现业务逻辑
    • 审批通过后的业务处理
    • 审批拒绝后的业务处理

示例:

// 请假审批
$approvalOrder = $approvalService->submit([
    'business_type' => 'leave',
    'business_id' => $leaveRequest->id,
    'title' => '请假申请 - ' . $user->name,
    'applicant_id' => $user->id,
    'flow_id' => $flow->id,
    'form_data' => [
        'reason' => $leaveRequest->reason,
        'start_date' => $leaveRequest->start_date,
        'end_date' => $leaveRequest->end_date,
        'approvers' => $selectedApprovers,
        'cc_users' => $selectedCcUsers,
    ],
]);

📨 消息模块(Notification Module)

核心模型

模型 说明 关键字段
FeishuMessage 消息映射表 order_id, node_id, user_id, message_id, status
FeishuMessageLog 消息日志表 action, order_id, message_id, status, request_data, response_data

消息类型

类型 场景 卡片样式
审批通知 发送给审批人 带通过/拒绝按钮
系统通知 发送给抄送人 带查看详情按钮
文本通知 自审批场景 纯文本,无按钮
结果通知 审批结果通知 显示操作结果

核心方法

方法 说明
sendApprovalNotification() 发送审批通知(带操作按钮)
sendSystemNotification() 发送系统通知(只读)
updateMessageCards() 更新消息卡片(移除按钮)
buildApprovalCard() 构建审批卡片
buildSystemCard() 构建系统通知卡片

消息生命周期

1. 发送消息
   - 调用飞书 API
   - 获取 message_id
   - 保存映射到 feishu_message
   - 记录日志到 feishu_message_log

2. 用户操作
   - 点击卡片按钮
   - 飞书回调处理
   - 执行审批逻辑

3. 更新消息(或签/单人场景)
   - 节点完成后触发
   - 更新其他审批人的卡片
   - 移除操作按钮
   - 记录日志

扩展新消息类型

步骤:

  1. 定义卡片构建方法
private function buildXXXCard(array $params): array
{
    // 返回卡片数据结构
}
  1. 添加发送方法
public function sendXXXNotification(array $params): bool
{
    // 构建卡片
    $card = $this->buildXXXCard($params);
    // 发送消息
    return $this->sendMessage($feishuUserId, $card);
}
  1. 添加更新方法(如需要)
public function updateXXXCards(...): bool
{
    // 更新逻辑
}

🔗 模块协作

协作流程

┌─────────────────────────────────────────────────────────────┐
│                    业务场景(如出差申请)                     │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
        ┌──────────────────────────────────────┐
        │   调用 ApprovalService::submit()     │
        │   - 创建审批订单                       │
        │   - 返回 approval_order               │
        └──────────────────────────────────────┘
                           │
                           ▼
        ┌──────────────────────────────────────┐
        │   调用 FeishuNotificationService     │
        │   - 发送消息给审批人                   │
        │   - 保存消息映射                       │
        │   - 记录日志                           │
        └──────────────────────────────────────┘
                           │
                           ▼
        ┌──────────────────────────────────────┐
        │   用户点击消息卡片操作                 │
        │   - 飞书回调到后端                     │
        │   - 调用 ApprovalService::approve()  │
        └──────────────────────────────────────┘
                           │
                           ▼
        ┌──────────────────────────────────────┐
        │   审批完成后                          │
        │   - 判断是否需要更新其他人的消息       │
        │   - 或签/单人:调用 updateMessageCards │
        │   - 会签:等待其他人审批               │
        └──────────────────────────────────────┘
                           │
                           ▼
        ┌──────────────────────────────────────┐
        │   流程结束                            │
        │   - 通知申请人结果                     │
        └──────────────────────────────────────┘

接口定义

审批模块 → 消息模块

// 发送审批通知
$notificationService->sendApprovalNotification([
    'user_id' => $approverId,
    'applicant_id' => $applicantId,
    'title' => '新的审批待办',
    'content' => '...',
    'url' => $detailUrl,
    'order_id' => $orderId,
    'node_id' => $nodeId,
]);

// 更新消息卡片(或签/单人场景)
$notificationService->updateMessageCards(
    $orderId,
    $nodeId,
    $excludeUserId,
    'approved',
    $operatorName
);

🚧 扩展指南

添加新的审批业务场景

示例:添加采购审批

  1. 创建数据库表
CREATE TABLE purchase_request (
    id bigint unsigned PRIMARY KEY,
    user_id bigint unsigned NOT NULL,
    reason varchar(500),
    amount decimal(10,2),
    status enum('draft', 'pending', 'approved', 'rejected'),
    approval_order_id bigint unsigned,
    ...
);
  1. 创建模型
class PurchaseRequest extends Model
{
    public function approvalOrder()
    {
        return $this->belongsTo(ApprovalOrder::class);
    }
}
  1. 创建服务
class PurchaseService
{
    private $approvalService;

    public function submit(array $data): PurchaseRequest
    {
        return DB::transaction(function () use ($data) {
            // 创建采购申请
            $purchase = PurchaseRequest::create($data);

            // 创建审批订单
            $approvalOrder = $this->approvalService->submit([
                'business_type' => 'purchase',
                'business_id' => $purchase->id,
                'title' => '采购申请 - ' . $data['item_name'],
                'applicant_id' => $data['user_id'],
                'flow_id' => $this->getFlowId(),
                'form_data' => [
                    'item_name' => $data['item_name'],
                    'amount' => $data['amount'],
                    'approvers' => $data['approvers'],
                    'cc_users' => $data['cc_users'],
                ],
            ]);

            // 关联审批订单
            $purchase->approval_order_id = $approvalOrder->id;
            $purchase->status = 'pending';
            $purchase->save();

            // 发送通知
            $this->sendNotifications($purchase, $approvalOrder);

            return $purchase;
        });
    }
}
  1. 创建控制器
class PurchaseController
{
    public function submit(Request $request): Response
    {
        $result = $this->service->submit($request->post());
        return $this->success($result);
    }
}

添加新的消息类型

示例:添加催办通知

class FeishuNotificationService
{
    /**
     * 发送催办通知
     */
    public function sendReminderNotification(array $params): bool
    {
        $userId = $params['user_id'];
        $card = $this->buildReminderCard($params);

        // 保存消息映射(如需要)
        if (isset($params['order_id']) && isset($params['node_id'])) {
            \app\model\FeishuMessage::saveMessage(
                $params['order_id'],
                $params['node_id'],
                $userId,
                $messageId
            );
        }

        return $this->sendMessage($feishuUserId, $card);
    }

    private function buildReminderCard(array $params): array
    {
        return [
            'header' => [
                'title' => ['content' => '⏰ 审批催办', 'tag' => 'plain_text'],
                'template' => 'orange',
            ],
            'elements' => [
                [
                    'tag' => 'div',
                    'text' => [
                        'content' => $params['content'],
                        'tag' => 'lark_md',
                    ],
                ],
                [
                    'tag' => 'action',
                    'actions' => [
                        [
                            'tag' => 'button',
                            'text' => ['content' => '立即处理', 'tag' => 'plain_text'],
                            'type' => 'default',
                            'url' => $params['url'],
                        ],
                    ],
                ],
            ],
        ];
    }
}

📊 数据流向图

完整审批流程数据流

用户提交申请
    ↓
┌───────────────────────────────────────────────────────┐
│ 1. 业务表(business_trip)                             │
│    - 创建业务记录                                       │
│    - status = 'draft'                                  │
└───────────────────────────────────────────────────────┘
    ↓
┌───────────────────────────────────────────────────────┐
│ 2. 审批订单(approval_order)                          │
│    - business_type = 'business_trip'                   │
│    - business_id = 业务记录ID                           │
│    - form_data = {                                     │
│        approvers: [用户选择的审批人],                   │
│        cc_users: [用户选择的抄送人],                    │
│        ...业务数据                                      │
│      }                                                 │
│    - current_nodes = [第一个节点ID]                    │
│    - status = 'pending'                                 │
└───────────────────────────────────────────────────────┘
    ↓
┌───────────────────────────────────────────────────────┐
│ 3. 消息映射(feishu_message)                          │
│    - order_id + node_id + user_id → message_id         │
│    - 每个审批人一条记录                                 │
└───────────────────────────────────────────────────────┘
    ↓
┌───────────────────────────────────────────────────────┐
│ 4. 飞书平台                                           │
│    - 发送卡片消息给审批人                              │
└───────────────────────────────────────────────────────┘
    ↓
审批人操作
    ↓
┌───────────────────────────────────────────────────────┐
│ 5. 审批记录(approval_record)                         │
│    - order_id, node_id, approver_id                    │
│    - action = 'approve' 或 'reject'                    │
└───────────────────────────────────────────────────────┘
    ↓
┌───────────────────────────────────────────────────────┐
│ 6. 节点完成判断                                        │
│    - 检查 form_data.approvers(实际选择的审批人)       │
│    - 节点类型决定完成条件                              │
│    - 或签/单人:立即完成,触发消息更新                  │
│    - 会签:等待所有人                                  │
└───────────────────────────────────────────────────────┘
    ↓
┌───────────────────────────────────────────────────────┐
│ 7. 消息更新(或签/单人场景)                           │
│    - 更新其他审批人的消息卡片                          │
│    - 移除操作按钮                                      │
│    - 标记消息状态 = 'updated'                          │
└───────────────────────────────────────────────────────┘
    ↓
┌───────────────────────────────────────────────────────┐
│ 8. 流程流转                                            │
│    - 移动到下一节点 或 完成流程                        │
│    - 通知下一节点审批人 / 通知申请人结果                │
└───────────────────────────────────────────────────────┘

🔍 调试与监控

消息日志查询

# 查看最近50条日志
curl "http://localhost:8787/debug/message-logs?debug=1&limit=50"

# 查看指定订单的日志
curl "http://localhost:8787/debug/message-logs?debug=1&order_id=123"

# 直接查询数据库
docker exec 1Panel-mysql-409s mysql -uroot -pmysql_xEsdPa xserver -e "
SELECT * FROM feishu_message_log
WHERE order_id = 123
ORDER BY created_at DESC;
"

日志字段说明

字段 说明
action send(发送)、update(更新)、update_card(更新卡片)
status success(成功)、failed(失败)
request_data 发送的数据(JSON)
response_data 飞书返回的数据(JSON)
error_message 错误信息(如有)

📝 注意事项

  1. 审批人来源:始终从 form_data.approvers 获取,不是从 node.approvers
  2. 消息更新:仅在或签/单人审批完成时触发
  3. 防止重复:审批前检查是否已操作过
  4. 事务处理:业务逻辑和审批创建在同一事务中
  5. 异常处理:记录所有失败日志,方便排查问题

🏢 多公司多飞书应用架构

架构概述

在多租户场景下,每个公司可以有独立的飞书应用配置:

  • 不同的 app_id
  • 不同的 app_secret
  • 不同的 bot 身份

核心约束

飞书API限制:只有发送消息的bot才能更新该消息。

这意味着:

  • 公司A的bot发送的消息,只能由公司A的bot更新
  • 公司B的bot无法更新公司A发送的消息
  • 在跨公司审批流程中,需要追踪每条消息的发送者

数据模型设计

feishu_message 表结构

字段 类型 说明
id bigint 主键
order_id bigint 审批订单ID
node_id bigint 审批节点ID
user_id bigint 接收消息的用户ID
company_id bigint 发送消息的公司ID
feishu_app_id int 发送消息的飞书应用ID
message_id varchar 飞书消息ID
msg_token varchar 消息token(用于更新)
status varchar 消息状态:active/updated/deleted

发送消息流程

// FeishuNotificationService::sendApprovalNotification()
public function sendApprovalNotification(array $params): bool
{
    $userId = $params['user_id'];
    $orderId = $params['order_id'];
    $nodeId = $params['node_id'];

    // 1. 获取用户信息(包含公司ID)
    $user = User::findOrFail($userId);

    // 2. 发送飞书消息
    $response = $this->sendMessage($user->feishu_user_id, $card);
    $messageId = $response['data']['message_id'];

    // 3. 保存消息映射(包含公司ID和飞书应用ID)
    \app\model\FeishuMessage::saveMessage(
        $orderId,
        $nodeId,
        $userId,
        $messageId,
        null,
        $user->company_id,    // ← 记录公司ID
        $this->appId          // ← 记录飞书应用ID
    );

    return true;
}

更新消息流程

// FeishuNotificationService::updateMessageCards()
public function updateMessageCards(
    int $orderId,
    int $nodeId,
    ?int $excludeUserId = null,
    string $result = 'approved',
    string $operatorName = ''
): bool {
    // 1. 获取该订单+节点的所有消息
    $messages = \app\model\FeishuMessage::getMessagesByOrderAndNode($orderId, $nodeId);

    foreach ($messages as $message) {
        // 2. 获取消息发送时的配置
        $companyId = $message->company_id;
        $feishuAppId = $message->feishu_app_id;

        if (!$companyId || !$feishuAppId) {
            error_log("❌ 消息记录缺少 company_id 或 feishu_app_id");
            continue;
        }

        // 3. 临时切换到该消息对应的飞书应用配置
        $originalCompanyId = $this->companyId;
        $originalAppId = $this->appId;

        $this->companyId = $companyId;
        $this->appId = $feishuAppId;

        try {
            // 4. 使用正确的飞书应用更新消息
            $response = HttpClient::httpPatchJson(
                "https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}",
                ['msg_type' => 'interactive', 'content' => $contentJson],
                ['content'],
                ['Authorization: Bearer ' . $this->getAccessToken()]
            );

            error_log("✅ 更新消息成功: {$message->message_id}");
        } catch (\Exception $e) {
            error_log("❌ 更新消息失败: {$e->getMessage()}");
        } finally {
            // 5. 恢复原始配置
            $this->companyId = $originalCompanyId;
            $this->appId = $originalAppId;
        }
    }

    return true;
}

跨公司审批场景示例

场景:公司A的员工发起审批,审批人包括公司A和公司B的员工

┌─────────────────────────────────────────────────────────────┐
│                     审批订单创建                             │
│  - 申请人:公司A-用户1                                       │
│  - 审批人:[公司A-用户2, 公司B-用户1]                         │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                   发送审批通知                                │
│  - 公司A-用户2:使用公司A的飞书应用发送                       │
│  - 公司B-用户1:使用公司B的飞书应用发送                       │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│              公司A-用户2 点击"拒绝"                          │
│  - 触发 ApprovalService::reject()                           │
│  - 订单状态变为 rejected                                     │
│  - 需要更新公司B-用户1的消息卡片                             │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                updateMessageCards() 执行                     │
│  - 查询 feishu_message 表                                    │
│  - 公司B-用户1的消息记录:                                   │
│    · company_id = 公司B的ID                                 │
│    · feishu_app_id = 公司B的飞书应用ID                       │
│  - 切换到公司B的飞书应用配置                                 │
│  - 调用 PATCH /messages/{id} 更新消息                        │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
                        ✅ 成功

关键实现要点

1. FeishuNotificationService 支持动态切换配置

class FeishuNotificationService
{
    private int $companyId;
    private string $appId;
    private string $appSecret;

    // 支持运行时切换配置
    public function switchCompany(int $companyId): void
    {
        $this->companyId = $companyId;
        $feishuConfig = $this->getFeishuConfig($companyId);
        $this->appId = $feishuConfig['app_id'];
        $this->appSecret = $feishuConfig['app_secret'];
    }

    private function getAccessToken(): string
    {
        $cacheKey = "feishu:access_token:{$this->companyId}";
        // 使用 company_id 维度的缓存key
        // ...
    }
}

2. HttpClient 支持预编码JSON字段

// httpPatchJson 方法支持某些字段预编码
HttpClient::httpPatchJson(
    $url,
    [
        'msg_type' => 'interactive',
        'content' => $preEncodedJson,  // 已编码的JSON字符串
    ],
    ['content'],  // 声明 content 字段需要预编码
    $headers
);

3. 数据完整性

-- 确保所有消息都有 company_id 和 feishu_app_id
UPDATE feishu_message fm
JOIN user u ON fm.user_id = u.id
SET fm.company_id = u.company_id,
    fm.feishu_app_id = (
        SELECT id FROM feishu_config
        WHERE company_id = u.company_id
        LIMIT 1
    )
WHERE fm.company_id IS NULL;

常见问题

Q1: 为什么会出现 "bot or user is not the message's sender" 错误?

A: 使用了错误的飞书应用配置去更新消息。确保:

  1. feishu_message 表正确记录了 company_id 和 feishu_app_id
  2. 更新消息时使用记录中的配置,而不是当前操作者的配置

Q2: 如何调试多公司消息更新问题?

A: 查看日志中的关键信息:

docker logs x-server-backend -f | grep -E "(company_id|feishu_app_id|更新消息)"

Q3: 历史数据缺少 company_id 怎么办?

A: 根据用户信息回填:

UPDATE feishu_message fm
JOIN user u ON fm.user_id = u.id
SET fm.company_id = u.company_id;

相关问题文档

详见 docs/PROJECT_ISSUES.md 第31条:多公司多飞书应用消息卡片更新失败


📚 相关文档


维护人: 开发团队 最后更新: 2026-03-01