Files
x-server-mobile/docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md
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

800 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 审批与消息模块架构文档
**版本**: 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