2026-02-27 18:01:17 +08:00
|
|
|
|
# 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}`
|
2026-03-01 12:29:29 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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
|