Files

800 lines
30 KiB
Markdown
Raw Permalink Normal View 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 |
### 节点类型
```php
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. **实现业务逻辑**
- 审批通过后的业务处理
- 审批拒绝后的业务处理
**示例:**
```php
// 请假审批
$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. **定义卡片构建方法**
```php
private function buildXXXCard(array $params): array
{
// 返回卡片数据结构
}
```
2. **添加发送方法**
```php
public function sendXXXNotification(array $params): bool
{
// 构建卡片
$card = $this->buildXXXCard($params);
// 发送消息
return $this->sendMessage($feishuUserId, $card);
}
```
3. **添加更新方法**(如需要)
```php
public function updateXXXCards(...): bool
{
// 更新逻辑
}
```
---
## 🔗 模块协作
### 协作流程
```
┌─────────────────────────────────────────────────────────────┐
│ 业务场景(如出差申请) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 调用 ApprovalService::submit() │
│ - 创建审批订单 │
│ - 返回 approval_order │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 调用 FeishuNotificationService │
│ - 发送消息给审批人 │
│ - 保存消息映射 │
│ - 记录日志 │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 用户点击消息卡片操作 │
│ - 飞书回调到后端 │
│ - 调用 ApprovalService::approve() │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 审批完成后 │
│ - 判断是否需要更新其他人的消息 │
│ - 或签/单人:调用 updateMessageCards │
│ - 会签:等待其他人审批 │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 流程结束 │
│ - 通知申请人结果 │
└──────────────────────────────────────┘
```
### 接口定义
**审批模块 → 消息模块**
```php
// 发送审批通知
$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. **创建数据库表**
```sql
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,
...
);
```
2. **创建模型**
```php
class PurchaseRequest extends Model
{
public function approvalOrder()
{
return $this->belongsTo(ApprovalOrder::class);
}
}
```
3. **创建服务**
```php
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;
});
}
}
```
4. **创建控制器**
```php
class PurchaseController
{
public function submit(Request $request): Response
{
$result = $this->service->submit($request->post());
return $this->success($result);
}
}
```
### 添加新的消息类型
**示例:添加催办通知**
```php
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. 流程流转 │
│ - 移动到下一节点 或 完成流程 │
│ - 通知下一节点审批人 / 通知申请人结果 │
└───────────────────────────────────────────────────────┘
```
---
## 🔍 调试与监控
### 消息日志查询
```bash
# 查看最近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 |
### 发送消息流程
```php
// 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;
}
```
### 更新消息流程
```php
// 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 支持动态切换配置
```php
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字段
```php
// httpPatchJson 方法支持某些字段预编码
HttpClient::httpPatchJson(
$url,
[
'msg_type' => 'interactive',
'content' => $preEncodedJson, // 已编码的JSON字符串
],
['content'], // 声明 content 字段需要预编码
$headers
);
```
#### 3. 数据完整性
```sql
-- 确保所有消息都有 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**: 查看日志中的关键信息:
```bash
docker logs x-server-backend -f | grep -E "(company_id|feishu_app_id|更新消息)"
```
#### Q3: 历史数据缺少 company_id 怎么办?
**A**: 根据用户信息回填:
```sql
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条:多公司多飞书应用消息卡片更新失败
---
## 📚 相关文档
- [数据库结构文档](./DATABASE_SCHEMA.md)
- [项目文档](./PROJECT.md)
- [问题汇总](./PROJECT_ISSUES.md)
---
**维护人**: 开发团队
**最后更新**: 2026-03-01