Files
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

1238 lines
40 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.
# X-Server Mobile 项目问题汇总
本文档记录项目开发过程中遇到的问题和解决方案,便于 CLAUDE 后续参考。
---
## 1. 审批列表数据混乱问题
**问题**:审批列表的三个 tab(我发起的、待我审批、我已审批)显示的数据不正确
**原因**:
- 前端状态管理问题:`setActiveTab` 是异步的,`loadData` 函数使用旧状态值
- React 18 StrictMode 导致 useEffect 执行两次,造成重复请求和状态混乱
**解决方案**:
```typescript
// 在 loadData 中添加 tab 参数,直接传递目标 tab 值
const loadData = async (pageNum: number = 1, isRefresh: boolean = false, tab?: string) => {
const currentTab = tab || activeTab // 使用传入的 tab 参数
// ...
}
// 切换 tab 时直接传递新值
const handleTabChange = (key: string) => {
setActiveTab(key as 'my' | 'pending' | 'approved')
loadData(1, true, key) // 直接传递 key
}
```
**相关文件**:`frontend/src/pages/approval/list.tsx`
---
## 2. 列表首次加载不显示数据问题(多次修复)
**问题**:首次进入审批列表时,"我发起的"不显示数据,需要切换其他 tab 再切换回来才显示
**原因(版本1)**:
- React 18 StrictMode 导致组件快速 mount/unmount
- useEffect cleanup 函数中清空了 requestIdRef,导致正在进行的 API 请求响应被跳过
**原因(版本2 - 最终问题)**:
- 使用 `loadingRef.current` 检查是否正在加载
- React 18 StrictMode 导致 useEffect 执行两次
- 第一次请求设置 `loadingRef.current = true`
- 第二次请求检查 `loadingRef.current` 为 true,被阻塞
- 导致第二次请求没有发送,数据无法加载
**解决方案(最终版本)**:
```typescript
// 移除 loadingRef,只使用 requestId 追踪请求
const requestIdRef = useRef<string>('')
const loadData = async (pageNum: number = 1, isRefresh: boolean = false, tab?: string) => {
// 生成唯一的请求 ID(包含随机数避免冲突)
const requestId = `${tab || activeTab}-${pageNum}-${Date.now()}-${Math.random()}`
requestIdRef.current = requestId
const currentTab = tab || activeTab
setLoading(true)
try {
const result = await approvalApi.getList({
type: currentTab,
page: pageNum,
page_size: 20,
})
// 检查是否是最新的请求
if (requestIdRef.current !== requestId) {
return // 跳过过期响应
}
const newList = result?.list || []
setList(newList)
setHasMore(newList.length >= 20)
setPage(pageNum)
} catch (error: any) {
console.error('Failed to load approvals:', error)
} finally {
setLoading(false)
}
}
// useEffect cleanup 不做任何操作
useEffect(() => {
loadData(1, true)
return () => {
// 不清空任何 ref,让异步请求能够正常完成处理
}
}, [])
```
**关键点**:
- ❌ 不要使用 `loadingRef` 检查来阻塞请求
- ❌ 不要在 cleanup 中清空 `requestIdRef`
- ✅ 只使用 `requestId` 来标识和忽略过期响应
- ✅ 在 requestId 中加入随机数避免 React 18 StrictMode 下的冲突
**相关文件**:`frontend/src/pages/approval/list.tsx`
---
## 3. 页码初始值导致请求错误数据
**问题**:前端请求 page=2 而不是 page=1
**原因**:
- `InfiniteScroll` 组件在挂载时触发 `loadMore`,因为 `hasMore` 初始值是 `true`
**解决方案**:
```typescript
const [hasMore, setHasMore] = useState(false) // 改为 false,避免挂载时立即触发加载更多
```
**相关文件**:`frontend/src/pages/approval/list.tsx`
---
## 4. 后端响应序列化问题
**问题**:后端返回数据时发生错误,进程不断重启
**原因**:
- Eloquent 模型对象存在循环引用,JSON 序列化失败
**解决方案**:
```php
// 在 ApprovalService::getList() 中将模型对象转换为纯数组
$listArray = $list->map(function ($item) {
return [
'id' => $item->id,
'title' => $item->title,
'business_type' => $item->business_type,
'status' => $item->status,
'created_at' => $item->created_at ? $item->created_at->toDateTimeString() : null,
'applicant' => $item->applicant ? [
'id' => $item->applicant->id,
'name' => $item->applicant->name,
] : null,
'flow' => $item->flow ? [
'id' => $item->flow->id,
'name' => $item->flow->name,
] : null,
];
})->toArray();
return [
'list' => $listArray,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
];
```
**相关文件**:`backend/app/service/ApprovalService.php`
---
## 5. 审批人和抄送人重复校验
**问题**:提交申请时,审批人和抄送人可能存在重复
**解决方案**:
```php
// 在 BusinessTripService::submit() 中添加校验
$approverIds = array_column($approvers, 'id');
$ccUserIds = array_column($ccUsers, 'id');
// 检查审批人列表中是否有重复
if (count($approverIds) !== count(array_unique($approverIds))) {
throw new \Exception('审批人列表中有重复用户');
}
// 检查抄送人列表中是否有重复
if (count($ccUserIds) !== count(array_unique($ccUserIds))) {
throw new \Exception('抄送人列表中有重复用户');
}
// 检查审批人和抄送人是否有重叠
$overlap = array_intersect($approverIds, $ccUserIds);
if (!empty($overlap)) {
throw new \Exception('审批人和抄送人不能是同一个人');
}
```
**相关文件**:`backend/app/service/BusinessTripService.php`
---
## 6. 测试数据节点配置错误
**问题**:测试数据与实际审批节点配置不匹配
**解决方案**:
- 修正数据库中订单的 `current_nodes` 字段,使用正确的节点 ID
- 确保节点 ID 与实际审批人匹配
**相关数据库表**:`approval_node`, `approval_order`
---
## 7. 双 header 问题
**问题**:部分页面有两个 header(MobileLayout 的 Header + 页面的 NavBar)
**解决方案**:
```typescript
// 在 MobileLayout.tsx 中全局隐藏 Header
const showHeader = false
```
**相关文件**:`frontend/src/components/layout/MobileLayout.tsx`
---
## 8. 申请页面 padding-bottom 过大
**问题**:申请页面底部留白太大
**解决方案**:
```css
.apply {
padding-bottom: 1.5rem; /* 从 80px 改为 1.5rem */
}
```
**相关文件**:`frontend/src/pages/business-trip/apply.module.css`
---
## 9. 前端文件路径大小写问题
**问题**:容器内使用小写目录 `service`,而本地编辑器可能是大写 `Service`
**解决方案**:
- 注意容器内路径为 `/app/app/service/`(小写)
- 编辑文件后需要复制到容器并重启服务
---
## 10. React 状态管理最佳实践
### 使用 ref 追踪异步状态
```typescript
const loadingRef = useRef(false) // 同步状态,避免闭包陷阱
const requestIdRef = useRef<string | null>(null) // 追踪请求 ID
```
### useEffect 依赖清理
```typescript
useEffect(() => {
// 不要在 cleanup 中清空正在使用的 ref
return () => {
mountedRef.current = false
// requestIdRef.current = null // ❌ 不要清空,让异步请求正常完成
}
}, [])
```
### 函数参数优先于状态
```typescript
// ❌ 错误:使用可能过期的状态
const loadData = async () => {
await api.get('/list', { type: activeTab }) // activeTab 可能是旧值
}
// ✅ 正确:直接传递参数
const loadData = async (tab?: string) => {
const currentTab = tab || activeTab
await api.get('/list', { type: currentTab })
}
```
---
## 开发注意事项
1. **数据库密码**:mysql_xEsdPa
2. **数据库名称**:xserver(不是 x_server_mobile)
3. **MySQL 容器名**:1Panel-mysql-409s
4. **后端容器内路径**:/app/(不是 /www/x-server-mobile/backend/)
5. **调试后端**:
- 编辑文件后需要复制到容器:`docker cp host_file container:/app/path`
- 重启后端:`docker compose restart backend`
- 或重载 workers:`docker exec x-server-backend php /app/webman reload`
6. **查看日志**:`docker logs x-server-backend -f --tail 50`
7. **React 18 StrictMode**:开发模式下 useEffect 会执行两次,需要注意副作用
---
## 历史问题汇总(早期开发阶段)
### 1. 缺少 webman/database 包
**解决方案**:`composer require webman/database:^2.0`
### 2. 登录状态没有正确设置到 Zustand store
**解决方案**:在登录成功后调用 `login(result.data.user, result.data.token)`
### 3. User 类型定义中 id 类型错误
**解决方案**:将 `id: string` 改为 `id: number`
### 4. 选择审批人后表单数据丢失
**原因**:每次导航时组件重新挂载,sessionStorage 在第一次读取后被清除
**解决方案**:使用独立的 sessionStorage key,不在 useEffect 中清除
### 5. DatePicker 点击无反应
**原因**:antd-mobile 5.x 的 DatePicker children render prop 点击事件绑定有问题
**解决方案**:使用 `visible` 属性手动控制弹框显示
### 6. 审批列表"我已审批"过滤逻辑错误
**问题**:显示了所有已审批的订单,包括还在审批中的
**解决方案**:只显示已完成状态的订单(approved、rejected、cancelled)
### 7. Dialog 按钮不显示
**问题**:使用 `confirmText`/`cancelText` 参数无效
**解决方案**:使用 `actions` prop 自定义按钮
### 8. React key 重复警告
**问题**:审批人和抄送人的 avatar 使用相同 key
**解决方案**:添加前缀区分 `approver-${id}` 和 `cc-${id}`
### 9. 飞书登录回调地址
**解决方案**:更新为 https://xsm.dev.hnxinyouhe.com/login
### 10. 审批节点数据库重复
**解决方案**:删除重复节点
### 11. 页面间状态丢失
**问题**:`navigate(-1)` 不传递 state,`location.state` 为 null
**解决方案**:使用 sessionStorage 传递数据
### 12. sessionStorage 使用规范
**问题**:多次选择导致状态丢失
**关键点**:
- 不同类型数据使用独立的 sessionStorage key
- 不在 useEffect 中清除 sessionStorage
- 每次挂载时从 sessionStorage 恢复状态
- 只在业务操作完成后清除 sessionStorage(提交、保存等)
### 13. 导航前草稿保存不及时
**解决方案**:在导航处理函数中立即保存草稿
### 14. 申请页面完整数据流
申请页面(如出差申请)需要处理三种场景:
**场景1:首次进入(新建申请)**
- 清除旧的 sessionStorage 数据
- 加载默认审批人/抄送人
- 初始化空表单
**场景2:从选择页面返回**
- 从 sessionStorage 恢复草稿数据
- 从 sessionStorage 恢复人员数据(审批人/抄送人)
- 日期字符串需要转换为 Date 对象
**场景3:提交/保存成功**
- 清除所有 sessionStorage 数据
- 导航回列表页
**实现要点:**
- 使用 `location.state?.fromSelection` 标记返回状态
- 使用独立的 sessionStorage key:`businessTripDraft`, `selectedApprovers`, `selectedCc`
- 导航前立即保存草稿,不依赖 useEffect
- 日期对象序列化/反序列化处理
**相关文件:**
- `frontend/src/pages/business-trip/apply.tsx` - 申请页面主逻辑
- `frontend/src/pages/business-trip/select-approver.tsx` - 审批人选择
- `frontend/src/pages/business-trip/select-cc.tsx` - 抄送人选择
### 16. Ant Design Mobile 全局样式
**必须导入**:`import 'antd-mobile/es/global'`,否则弹框、遮罩等组件无法正常显示
### 17. Selector 组件隐藏对号图标
**解决方案**:使用 `showCheckMark={false}` 属性
### 18. Bootstrap 接口签名
**方法签名**:`public static function start(?\Workerman\Worker $worker): void`
### 19. PSR-4 自动加载
**目录名必须是 PascalCase**(Controller、Model、Service、Util)
### 20. Docker 容器 CMD
**使用前台运行**:`php start.php start`(不要用 `-d`),避免容器退出
### 21. 发起人也是审批人时"待我审批"不显示
**问题**:用户提交申请后,既是发起人也是审批人,但"待我审批"中不显示该订单
**原因**:
- `BusinessTripService::submit()` 中硬编码 `current_nodes => [1]`
- 但数据库中节点 1 的审批人是其他用户(如用户 2)
- 提交申请时没有根据用户选择的审批人创建/更新节点
**解决方案**:
```php
// 根据审批人创建审批节点
$approverIds = array_column($approvers, 'id');
$node = ApprovalNode::where('flow_id', $flow->id)
->where('order', 0)
->first();
if (!$node) {
// 创建第一个审批节点
$node = ApprovalNode::create([
'flow_id' => $flow->id,
'type' => count($approverIds) > 1 ? 'and_approve' : 'approver',
'name' => '审批',
'order' => 0,
'approvers' => $approverIds,
]);
} else {
// 更新现有节点的审批人
$node->approvers = $approverIds;
$node->save();
}
// 使用创建的节点 ID
$approvalOrder = ApprovalOrder::create([
// ...
'current_nodes' => [$node->id],
// ...
]);
```
**相关文件**:`backend/app/service/BusinessTripService.php`
### 22. 申请提交后跳转到错误的页面
**问题**:提交申请后跳转到出差列表 (`/business-trip`),而不是审批列表 (`/approval`)
**解决方案**:
```typescript
// 修改申请页面跳转逻辑
Toast.show(t('businessTrip.submitSuccess'))
navigate('/approval') // 改为跳转到审批列表
```
**相关文件**:`frontend/src/pages/business-trip/apply.tsx`
### 23. 发起人自动审批通过逻辑
**需求**:当发起人选择的审批人中包含自己时,提交时自动标记为已审批
**实现逻辑**:
1. 检查发起人是否在审批人列表中
2. 如果是,自动创建审批通过记录
3. 根据节点类型判断是否完成:
- 单人审批(approver):发起人审批后节点完成
- 或签(or_approve):发起人审批后节点完成
- 会签(and_approve):需要所有审批人审批才完成
4. 节点完成后移动到下一节点或完成审批
**解决方案**:
```php
// 如果发起人在审批人列表中,自动标记为已审批
if (in_array($userId, $approverIds)) {
ApprovalRecord::create([
'order_id' => $approvalOrder->id,
'node_id' => $node->id,
'node_type' => $node->type,
'approver_id' => $userId,
'action' => 'approve',
'comment' => '发起人自动审批通过',
]);
// 判断节点是否完成
$nodeCompleted = false;
if ($node->type === 'approver') {
// 单人审批:发起人即完成
$nodeCompleted = true;
} elseif ($node->type === 'or_approve') {
// 或签:任一人通过即可
$nodeCompleted = true;
} elseif ($node->type === 'and_approve') {
// 会签:需要所有人通过
$approvedCount = ApprovalRecord::where('order_id', $approvalOrder->id)
->where('node_id', $node->id)
->where('action', 'approve')
->count();
if ($approvedCount >= count($approverIds)) {
$nodeCompleted = true;
}
}
// 如果节点完成,移动到下一节点
if ($nodeCompleted) {
$nextNodes = ApprovalNode::where('flow_id', $flow->id)
->where('order', '>', 0)
->orderBy('order')
->get();
if ($nextNodes->isEmpty()) {
// 审批完成
$approvalOrder->status = ApprovalOrder::STATUS_APPROVED;
$approvalOrder->current_nodes = [];
} else {
// 移动到下一节点
$approvalOrder->current_nodes = $nextNodes->pluck('id')->toArray();
}
$approvalOrder->save();
}
}
```
**相关文件**:`backend/app/service/BusinessTripService.php`
---
## 文件修改记录
### 前端文件
- `frontend/src/pages/approval/list.tsx` - 修复状态管理和请求逻辑
- `frontend/src/components/layout/MobileLayout.tsx` - 隐藏 Header
- `frontend/src/pages/business-trip/apply.module.css` - 调整 padding-bottom
- `frontend/src/pages/business-trip/apply.tsx` - 修复 key 重复、草稿保存
- `frontend/src/pages/approval/detail.tsx` - Dialog 按钮修复、字段标签中文显示
- `frontend/src/services/api.ts` - 添加请求日志
- `frontend/src/main.tsx` - 导入 antd-mobile 全局样式
### 后端文件
- `backend/app/service/BusinessTripService.php` - 添加重复校验
- `backend/app/service/ApprovalService.php` - 数据序列化修复
- `backend/app/controller/ApprovalController.php` - 响应返回修复
- `backend/app/model/ApprovalOrder.php` - 模型定义
- `backend/config/database.php` - 数据库配置
- `backend/config/route.php` - 路由配置
### 数据库
- `approval_node` 表 - 删除重复节点
- `approval_order` 表 - 修正 current_nodes 字段
---
## 24. 多端飞书登录功能实现
### 需求背景
支持两种登录场景:
1. **飞书客户端内**:静默自动登录,无需用户操作
2. **飞书客户端外**:邮箱密码登录 + 飞书扫码登录
### 实现方案
#### 1. 飞书客户端内 - 静默授权
**环境检测**:
```typescript
const ua = navigator.userAgent.toLowerCase()
const isInFeishu = ua.includes('feishu') || ua.includes('lark') ||
(window as any).ttjsBridge !== undefined
```
**调用 ttjsBridge 获取授权码**:
```typescript
if ((window as any).ttjsBridge) {
(window as any).ttjsBridge.getAuthCode({
success: (authCode: string) => {
handleFeishuLogin(authCode)
},
fail: (error: any) => {
Toast.show('获取授权失败,请重试')
},
})
}
```
**相关文件**:`frontend/src/pages/login/index.tsx`
#### 2. 飞书客户端外 - 扫码登录
**后端接口**:
1. `POST /api/v1/auth/qrcode-login` - 生成登录会话和二维码 URL
2. `GET /api/v1/auth/qrcode-login/status?session_id={id}` - 轮询登录状态
3. `GET /login/qrcode/{id}` - 飞书授权回调地址
**数据库表**:`feishu_login_session`
- `session_id` - 会话 ID(UUID)
- `status` - pending | scanning | success | expired
- `auth_url` - 飞书授权 URL
- `expires_at` - 过期时间(2分钟)
**前端实现**:
- 使用 `qrcode.react` 生成二维码
- 每 2 秒轮询一次登录状态
- 状态更新:loading → pending → scanning → success
**相关文件**:
- `frontend/src/pages/login/QRCodeLogin.tsx` - 二维码登录组件
- `backend/app/controller/AuthController.php` - 后端接口
- `backend/app/model/FeishuLoginSession.php` - 会话模型
#### 3. 登录流程图
```
飞书内:
┌─────────┐ ttjsBridge ┌──────────┐ auth_code ┌─────────┐
│ 用户 │ ───────────────> │ 飞书API │ ──────────────> │ 后端 │
└─────────┘ getAuthCode() └──────────┘ 静默授权 └─────────┘
│
v
返回 token + user
自动登录成功
飞书外(扫码):
┌─────────┐ ┌──────────┐ ┌─────────┐
│ 用户 │ ──> 显示二维码 ──> │ 手机扫码 │ ──> 飞书授权 ──> │ 后端 │
└─────────┘ └──────────┘ └─────────┘
│ │
│ 每2秒轮询 status │
│<─────────────────────────────────────────────────────────────│
│ v
│ 返回 token + user
│<─────────────────────────────────────────────────────────────│
│
v
自动登录成功
```
### 关键技术点
1. **ttjsBridge 类型声明**:
```typescript
declare global {
interface Window {
ttjsBridge?: {
getAuthCode: (params: any) => void
}
}
}
```
2. **登录成功事件通知**:
```typescript
// QRCodeLogin 组件触发事件
window.dispatchEvent(new CustomEvent('feishu-login-success', {
detail: { user, token }
}))
// 主登录页监听事件
useEffect(() => {
const handleFeishuLoginSuccess = (e: any) => {
const { user, token } = e.detail
login(user, token)
}
window.addEventListener('feishu-login-success', handleFeishuLoginSuccess)
return () => {
window.removeEventListener('feishu-login-success', handleFeishuLoginSuccess)
}
}, [])
```
3. **二维码状态管理**:
```typescript
const [status, setStatus] = useState<'loading' | 'pending' | 'scanning' | 'success' | 'expired'>('loading')
const getStatusColor = () => {
switch (status) {
case 'pending': return '#1677ff' // 蓝色 - 待扫码
case 'scanning': return '#faad14' // 橙色 - 已扫码待确认
case 'success': return '#52c41a' // 绿色 - 成功
case 'expired': return '#ff4d4f' // 红色 - 过期
}
}
```
### 依赖安装
```bash
npm install qrcode.react
```
### 数据库迁移
```sql
CREATE TABLE IF NOT EXISTS `feishu_login_session` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`company_id` bigint unsigned NOT NULL COMMENT '公司ID',
`tenant_key` varchar(64) NOT NULL COMMENT '租户密钥',
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending: 待扫码, scanning: 已扫码待授权, success: 登录成功, expired: 已过期',
`user_id` bigint unsigned DEFAULT NULL COMMENT '用户ID(登录成功后填充)',
`session_id` varchar(64) NOT NULL COMMENT '会话ID',
`auth_url` text COMMENT '飞书授权URL',
`expires_at` datetime NOT NULL COMMENT '过期时间',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_id` (`session_id`),
KEY `idx_company_status` (`company_id`, `status`),
KEY `idx_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='飞书扫码登录会话表';
```
### 配置要求
- 飞书应用 `tenant_key`: `14e7bc0fe5985740`
- 飞书授权回调地址: `https://xsm.dev.hnxinyouhe.com/login/qrcode/{id}`
---
## 25. 审批列表 Tab 状态持久化问题
**问题**:查看审批详情后返回上一页,tab 重置为"我发起的",而不是保持用户之前选择的 tab
**原因**:
1. React Router 的 `navigate(-1)` 不传递 `location.state`
2. 页面组件重新挂载时,`useState` 初始值恢复为默认值
3. 即使使用 `navigate(path, { state: { fromTab: activeTab } })`,`navigate(-1)` 时 state 仍然为 null
**解决方案**:
使用 `sessionStorage` 持久化 tab 状态,而不是依赖 `location.state`:
```typescript
// 优先从 sessionStorage 恢复 tab,其次从 location.state,最后默认为 'my'
const savedTab = sessionStorage.getItem('approval-tab') as string
const stateTab = ((location.state as any)?.fromTab as string) || undefined
const initialTab = (savedTab && ['my', 'pending', 'approved', 'cc'].includes(savedTab)) ? savedTab : (stateTab || 'my')
const [activeTab, setActiveTab] = useState<'my' | 'pending' | 'approved' | 'cc'>(initialTab)
// 切换标签时保存到 sessionStorage
const handleTabChange = (key: string) => {
setActiveTab(key as 'my' | 'pending' | 'approved' | 'cc')
sessionStorage.setItem('approval-tab', key) // 保存到 sessionStorage
setList([])
setHasMore(false)
setPage(1)
loadData(1, true, key)
}
// 查看详情时同时保存 state(兼容其他场景)
const handleViewDetail = (id: number) => {
navigate(`/approval/${id}`, { state: { fromTab: activeTab } })
}
```
**相关文件**:
- `frontend/src/pages/approval/list.tsx`
- `frontend/src/pages/business-trip/list.tsx`
---
## 26. "我已审批"列表不显示已审批订单
**问题**:用户审批完成后,在"我已审批"列表中看不到该订单
**根本原因**:架构问题 - 数据模型混淆
### 问题分析
审批系统中有两个数据来源,它们的职责混淆了:
1. **`approval_node.approvers`** - 静态模板数据
- 用途:定义审批流程模板中每个节点的默认审批人配置
- 问题:运行时不会根据用户实际选择的审批人更新
2. **`approval_order.form_data.approvers`** - 运行时数据
- 用途:存储用户提交申请时实际选择的审批人
- 正确:这才是判断审批状态应该使用的数据
### 历史演变
| 时间 | BusinessTripService | ApprovalService | 结果 |
|------|---------------------|-----------------|------|
| 早期 | 使用 `approval_node.approvers` | 使用 `approval_node.approvers` | 一致但错误 |
| 修复后 | 使用 `form_data.approvers` | 使用 `approval_node.approvers` | 不一致!|
| 现在 | 使用 `form_data.approvers` | 使用 `form_data.approvers` | 一致且正确 |
### 代码修复
**ApprovalService::isNodeCompleted()** - 判断节点是否完成
```php
// ❌ 旧代码:从 approval_node 获取审批人(错误)
$node = ApprovalNode::findOrFail($nodeId);
$approvers = $node->approvers ?? []; // 静态模板数据,可能为空
// ✅ 新代码:从 form_data 获取审批人(正确)
$order = ApprovalOrder::find($orderId);
$formData = $order->form_data ?? [];
$approvers = $formData['approvers'] ?? []; // 运行时数据,用户实际选择
```
**ApprovalService::getPendingOrderIds()** - 获取待审批订单
```php
// ❌ 旧代码
$node = ApprovalNode::whereIn('id', $currentNodeIds)->first();
$approvers = $node->approvers ?? [];
// ✅ 新代码
$formData = $order->form_data ?? [];
$approvers = $formData['approvers'] ?? [];
```
**兼容性处理**:
```php
// 兼容两种格式:[{id:1, name:'...'}] 或 [1]
if (!empty($approvers) && is_array($approvers[0] ?? null) && isset($approvers[0]['id'])) {
$approverIds = array_column($approvers, 'id');
} else {
$approverIds = $approvers;
}
```
**相关文件**:`backend/app/service/ApprovalService.php`
---
## 27. 审批流程不自动跳过通知节点
**问题**:审批节点完成后,下一节点是"通知"节点时,流程状态仍为"审批中",`current_nodes` 包含通知节点 ID
**原因**:
- `ApprovalService::approve()` 没有检查下一节点类型
- 通知节点(notify)不应该阻止流程完成,应该自动跳过
**解决方案**:
```php
// 移动到下一节点
$nextNodes = $this->getNextNodes($order->flow, $currentNode->order);
if (empty($nextNodes)) {
// 没有下一节点,审批完成
$order->status = ApprovalOrder::STATUS_APPROVED;
$order->current_nodes = [];
} else {
// 检查第一个下一节点
$firstNextNode = $nextNodes[0] ?? null;
if ($firstNextNode && $firstNextNode['type'] === 'notify') {
// 下一个是通知节点,跳过并完成流程
$order->status = ApprovalOrder::STATUS_APPROVED;
$order->current_nodes = [];
} else {
// 下一个是审批节点,移动到该节点
$order->current_nodes = collect($nextNodes)->pluck('id')->toArray();
}
}
```
**相关文件**:`backend/app/service/ApprovalService.php`
---
## 28. 架构问题总结 - 为什么问题一直出现
### 根本原因:数据模型职责不清晰
审批系统长期存在问题的根源是**静态模板数据**和**运行时数据**混淆:
| 数据源 | 表/字段 | 用途 | 问题 |
|--------|---------|------|------|
| **静态模板** | `approval_node.approvers` | 流程模板中每个节点的默认审批人 | 不应随用户选择变化 |
| **运行时数据** | `approval_order.form_data.approvers` | 用户提交时实际选择的审批人 | 判断审批状态的正确来源 |
### 为什么会反复出现问题
1. **两个服务使用不同数据源**
- `BusinessTripService` 修复后使用 `form_data`
- `ApprovalService` 仍使用 `approval_node`
- 导致相同操作在不同页面结果不一致
2. **没有明确的数据访问规范**
- 开发时容易混淆应该读取哪个数据源
- 缺少代码注释说明数据来源的用途
3. **缺少单元测试**
- 数据逻辑变更后没有自动化测试验证
- 依赖手工测试,容易遗漏边界情况
### 长期解决方案
1. **代码层面**:
- 在 `ApprovalNode` 模型上添加注释,明确 `approvers` 是模板数据
- 在 `ApprovalOrder` 模型上添加辅助方法 `getRuntimeApprovers()`,统一访问入口
- 添加单元测试覆盖所有审批流程场景
2. **数据库层面**:
- 添加外键约束确保数据一致性
- 考虑将 `approval_node.approvers` 重命名为 `default_approvers`,更清晰表达语义
3. **文档层面**:
- 在开发文档中明确说明数据模型的设计原则
- 提供常见问题的排查指南
---
## 29. 数据库完整性建议
### 当前问题
- `approval_order` 表记录可能被孤立(业务表引用了不存在的 approval_order_id)
- 没有外键约束确保引用完整性
- 清理数据时可能遗留孤立记录
### 建议的修复方案
```sql
-- 1. 添加外键约束(确保数据一致性)
ALTER TABLE business_trip
ADD CONSTRAINT fk_business_trip_approval_order
FOREIGN KEY (approval_order_id) REFERENCES approval_order(id)
ON DELETE SET NULL ON UPDATE CASCADE;
-- 2. 清理孤立数据
DELETE FROM approval_record
WHERE order_id NOT IN (SELECT id FROM approval_order);
-- 3. 添加触发器自动维护一致性
DELIMITER $$
CREATE TRIGGER before_approval_order_delete
BEFORE DELETE ON approval_order
FOR EACH ROW
BEGIN
UPDATE business_trip
SET approval_order_id = NULL
WHERE approval_order_id = OLD.id;
END$$
DELIMITER ;
```
**相关文件**:`/tmp/fix_proposal.sql`
---
## 最新修改记录(2025-02)
### 前端文件
- `frontend/src/pages/approval/list.tsx` - 添加"抄送我的"tab,使用 sessionStorage 持久化 tab 状态
- `frontend/src/pages/business-trip/list.tsx` - 使用 sessionStorage 持久化 tab 状态
- `frontend/src/modules/approval/types.ts` - 添加 'cc' 类型到 ApprovalListParams
### 后端文件
- `backend/app/service/ApprovalService.php`
- 添加 'cc' case 支持"抄送我的"列表
- 修复 `isNodeCompleted()` 使用 `form_data.approvers`
- 修复 `getPendingOrderIds()` 使用 `form_data.approvers`
- 添加跳过 notify 节点逻辑
### Git 提交记录
- `e904d5c` - fix: 修复 ApprovalService 使用 form_data.approvers
- `8ce070b` - fix: 使用 sessionStorage 保持 tab 状态
- `f3a9e44` - fix: 返回列表时保持之前的 tab 状态
- `ea7f0f8` - feat: 添加"抄送我的"审批列表选项
---
## 30. 飞书卡片"查看详情"跳转错误(多公司支持)
**问题**:
1. 点击飞书卡片"查看详情"后,页面闪两下然后跳转到 `/login` 页面,没有 redirect 参数
2. 最终 URL 变成 `/login?redirect=%2Flogin`,而不是预期的 `/login?redirect=/approval/detail/103?company=2`
**根本原因**:
1. **路由配置不匹配**:后端生成 `/approval/detail/{id}` 格式的 URL,但前端路由只有 `/approval/:id`,导致触发通配符路由 `*` 直接跳转到 `/login`
2. **详情页被 MobileLayout 包裹**:详情页在 MobileLayout 的 children 中,MobileLayout 的登录检查在组件挂载前就拦截了请求,直接跳转到 `/login`,没有保留原始 URL
3. **登录页没有处理已登录状态**:用户已登录访问详情页时,仍然显示登录界面
**解决方案**:
### 1. 修复路由配置
```typescript
// frontend/src/router/index.tsx
const router = createBrowserRouter([
{
path: '/',
element: <MobileLayout />,
children: [
// ... 其他路由
{ path: 'approval/:id', element: <ApprovalDetail /> }, // 兼容旧格式
],
},
// 详情页移到顶层,支持未登录访问(页面自己处理登录检查)
{ path: '/approval/detail/:id', element: <ApprovalDetail /> },
{ path: '/business-trip/detail/:id', element: <BusinessTripDetail /> },
// ... 其他顶层路由
{ path: '*', element: <Navigate to='/login' replace /> },
])
```
### 2. 修改后端 URL 生成逻辑
```php
// backend/app/service/ApprovalService.php
private function getDetailUrl(ApprovalOrder $order): string
{
$baseUrl = 'https://xsm.dev.hnxinyouhe.com';
// 统一使用审批详情页,不再跳转到具体业务模块的详情页
return "{$baseUrl}/approval/detail/{$order->id}";
}
```
### 3. 修改 BusinessTripService 通知 URL
```php
// backend/app/service/BusinessTripService.php
// 构建审批链接(统一使用审批详情页)
$approvalUrl = "https://xsm.dev.hnxinyouhe.com/approval/detail/{$approvalOrder->id}";
// 使用 $approvalUrl 而不是 $detailUrl
$notificationService->sendApprovalNotification([
'url' => $approvalUrl,
// ...
]);
```
### 4. 修复登录页登录状态检查
```typescript
// frontend/src/pages/login/index.tsx
useEffect(() => {
// 首先检查是否已登录
const token = localStorage.getItem('token')
const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect')
// 如果有 token,说明已登录
if (token) {
// 如果有 redirect 参数,跳转到目标页面
if (redirect) {
window.location.href = redirect
return
}
// 否则跳转到 dashboard
navigate('/dashboard')
return
}
// 没有token,继续登录流程
setLoading(false)
// ...
}, [user, login, navigate])
```
### 5. 禁用 OPcache(开发环境)
```bash
# 创建配置文件禁用 OPcache
echo 'opcache.enable=0' >> /usr/local/etc/php/conf.d/99-disable-opcache.ini
```
**相关文件**:
- `frontend/src/router/index.tsx` - 路由配置
- `backend/app/service/ApprovalService.php` - URL 生成
- `backend/app/service/BusinessTripService.php` - 通知 URL 生成
- `frontend/src/pages/login/index.tsx` - 登录状态检查
- `frontend/src/pages/approval/detail.tsx` - 详情页登录检查
- `frontend/src/pages/business-trip/detail.tsx` - 详情页登录检查
**注意事项**:
1. 详情页需要有自己独立的登录检查逻辑,不能依赖 MobileLayout
2. 登录页需要优先检查 token,已登录则直接跳转
3. 多公司支持:URL 需要包含 `?company={id}` 参数
4. 飞书卡片通知 URL 格式:`https://xsm.dev.hnxinyouhe.com/approval/detail/{order_id}?company={company_id}`
---
## 31. 多公司多飞书应用消息卡片更新失败
**问题**:
在多公司多飞书应用场景下,当多个审批人中有一人拒绝/通过时,其他审批人的消息卡片没有更新,仍然显示操作按钮。
**场景描述**:
- 公司A和公司B的员工共同在一个审批流程中
- 公司A的bot发送了消息给公司A的员工
- 公司B的bot发送了消息给公司B的员工
- 当公司A的员工操作后,需要更新公司B员工的消息卡片
- 使用公司A的bot去更新公司B发送的消息 → 失败
**错误信息**:
```
HTTP 400 - bot or user is not the message's sender
```
**根本原因**:
1. **飞书API限制**:只有发送消息的bot才能更新该消息
2. **架构问题**:多公司场景下,每个公司有独立的飞书应用(app_id + app_secret)
3. **数据缺失**:`feishu_message` 表没有记录是哪个飞书应用发送的消息
**解决方案**:
### 1. 数据库迁移
```sql
-- 添加 company_id 和 feishu_app_id 字段
ALTER TABLE feishu_message
ADD COLUMN company_id BIGINT UNSIGNED NULL AFTER user_id,
ADD COLUMN feishu_app_id INT UNSIGNED NULL AFTER company_id,
ADD INDEX idx_company (company_id);
-- 更新现有数据(如有)
UPDATE feishu_message fm
JOIN approval_order ao ON fm.order_id = ao.id
JOIN user u ON ao.applicant_id = u.id
SET fm.company_id = u.company_id;
```
### 2. 修改 FeishuMessage 模型
```php
// backend/app/model/FeishuMessage.php
protected $fillable = [
'order_id',
'node_id',
'user_id',
'company_id', // 新增
'feishu_app_id', // 新增
'message_id',
'msg_token',
'status',
];
public static function saveMessage(
int $orderId,
int $nodeId,
int $userId,
string $messageId,
?string $msgToken = null,
?int $companyId = null, // 新增参数
?int $feishuAppId = null // 新增参数
): self {
return self::updateOrCreate(
[
'order_id' => $orderId,
'node_id' => $nodeId,
'user_id' => $userId,
],
[
'message_id' => $messageId,
'msg_token' => $msgToken,
'company_id' => $companyId,
'feishu_app_id' => $feishuAppId,
'status' => self::STATUS_ACTIVE,
]
);
}
```
### 3. 修改发送逻辑
```php
// backend/app/service/FeishuNotificationService.php
// 发送消息时保存 company_id 和 feishu_app_id
\app\model\FeishuMessage::saveMessage(
$orderId,
$nodeId,
$userId,
$messageId,
null,
$user->company_id, // 保存公司ID
$this->appId // 保存飞书应用ID
);
```
### 4. 修改更新逻辑
```php
// backend/app/service/FeishuNotificationService.php
public function updateMessageCards(
int $orderId,
int $nodeId,
?int $excludeUserId = null,
string $result = 'approved',
string $operatorName = ''
): bool {
$messages = \app\model\FeishuMessage::getMessagesByOrderAndNode($orderId, $nodeId);
foreach ($messages as $message) {
// 获取消息发送时的公司ID和飞书应用ID
$companyId = $message->company_id;
$feishuAppId = $message->feishu_app_id;
if (!$companyId || !$feishuAppId) {
error_log("❌ 消息记录缺少 company_id 或 feishu_app_id");
continue;
}
// 临时切换到该消息对应的飞书应用配置
$originalCompanyId = $this->companyId;
$originalAppId = $this->appId;
$this->companyId = $companyId;
$this->appId = $feishuAppId;
try {
$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()]
);
} finally {
// 恢复原始配置
$this->companyId = $originalCompanyId;
$this->appId = $originalAppId;
}
}
return true;
}
```
### 5. 添加 httpPatchJson 方法
```php
// backend/app/util/HttpClient.php
public static function httpPatchJson(
string $url,
array $data,
array $preencodedKeys = [],
array $headers = [],
int $timeout = 0
): array {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
// 手动构建 JSON
$jsonParts = [];
foreach ($data as $key => $value) {
$keyJson = json_encode($key, JSON_UNESCAPED_UNICODE);
if (in_array($key, $preencodedKeys)) {
$jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
$jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE);
}
}
$jsonPayload = '{' . implode(',', $jsonParts) . '}';
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
// ... 其余代码
}
```
**关键点**:
1. ✅ 每个飞书应用只能更新自己发送的消息
2. ✅ 需要在数据库中记录每条消息是由哪个飞书应用发送的
3. ✅ 更新消息时使用原始发送者的飞书应用配置
4. ✅ 使用 PATCH `/messages/{id}` 而不是 POST `/messages/{id}/update`
**相关文件**:
- `backend/app/model/FeishuMessage.php` - 添加 company_id 和 feishu_app_id 字段
- `backend/app/service/FeishuNotificationService.php` - 修改发送和更新逻辑
- `backend/app/util/HttpClient.php` - 添加 httpPatchJson 方法
- `docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md` - 架构文档更新
**飞书API文档**:
- 更新消息:https://open.feishu.cn/document/server-docs/im-v1/message/update_messages