b90f901fd9
- 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>
30 KiB
30 KiB
审批与消息模块架构文档
版本: 1.0.0 更新时间: 2026-02-28 状态: 设计中
📋 概述
系统采用模块化设计,审批模块和消息模块是两个独立但可联合使用的核心模块。
设计原则
- 模块独立:各模块可独立部署和使用
- 松耦合:模块间通过接口通信,不直接依赖
- 可扩展:支持新增业务场景(如请假、采购、报销等)
- 统一协议:所有业务场景遵循相同的审批和消息协议
🏗️ 模块架构
┌─────────────────────────────────────────────────────────────┐
│ 业务场景层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 出差审批 │ │ 请假审批 │ │ 采购审批 │ │ 费用报销 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 审批引擎层(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() |
判断节点是否完成 |
扩展新业务场景
步骤:
- 创建业务模型(如
LeaveRequest) - 定义流程类型(
business_type = 'leave') - 实现审批逻辑
- 调用
ApprovalService::submit()创建审批订单 - 在
form_data中存储业务数据
- 调用
- 实现业务逻辑
- 审批通过后的业务处理
- 审批拒绝后的业务处理
示例:
// 请假审批
$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. 更新消息(或签/单人场景)
- 节点完成后触发
- 更新其他审批人的卡片
- 移除操作按钮
- 记录日志
扩展新消息类型
步骤:
- 定义卡片构建方法
private function buildXXXCard(array $params): array
{
// 返回卡片数据结构
}
- 添加发送方法
public function sendXXXNotification(array $params): bool
{
// 构建卡片
$card = $this->buildXXXCard($params);
// 发送消息
return $this->sendMessage($feishuUserId, $card);
}
- 添加更新方法(如需要)
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
);
🚧 扩展指南
添加新的审批业务场景
示例:添加采购审批
- 创建数据库表
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,
...
);
- 创建模型
class PurchaseRequest extends Model
{
public function approvalOrder()
{
return $this->belongsTo(ApprovalOrder::class);
}
}
- 创建服务
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;
});
}
}
- 创建控制器
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 |
错误信息(如有) |
📝 注意事项
- 审批人来源:始终从
form_data.approvers获取,不是从node.approvers - 消息更新:仅在或签/单人审批完成时触发
- 防止重复:审批前检查是否已操作过
- 事务处理:业务逻辑和审批创建在同一事务中
- 异常处理:记录所有失败日志,方便排查问题
🏢 多公司多飞书应用架构
架构概述
在多租户场景下,每个公司可以有独立的飞书应用配置:
- 不同的
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: 使用了错误的飞书应用配置去更新消息。确保:
feishu_message表正确记录了company_id和feishu_app_id- 更新消息时使用记录中的配置,而不是当前操作者的配置
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