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

40 KiB
Raw Blame History

X-Server Mobile 项目问题汇总

本文档记录项目开发过程中遇到的问题和解决方案,便于 CLAUDE 后续参考。


1. 审批列表数据混乱问题

问题:审批列表的三个 tab(我发起的、待我审批、我已审批)显示的数据不正确

原因:

  • 前端状态管理问题:setActiveTab 是异步的,loadData 函数使用旧状态值
  • React 18 StrictMode 导致 useEffect 执行两次,造成重复请求和状态混乱

解决方案:

// 在 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,被阻塞
  • 导致第二次请求没有发送,数据无法加载

解决方案(最终版本):

// 移除 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

解决方案:

const [hasMore, setHasMore] = useState(false)  // 改为 false,避免挂载时立即触发加载更多

相关文件:frontend/src/pages/approval/list.tsx


4. 后端响应序列化问题

问题:后端返回数据时发生错误,进程不断重启

原因:

  • Eloquent 模型对象存在循环引用,JSON 序列化失败

解决方案:

// 在 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. 审批人和抄送人重复校验

问题:提交申请时,审批人和抄送人可能存在重复

解决方案:

// 在 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)

解决方案:

// 在 MobileLayout.tsx 中全局隐藏 Header
const showHeader = false

相关文件:frontend/src/components/layout/MobileLayout.tsx


8. 申请页面 padding-bottom 过大

问题:申请页面底部留白太大

解决方案:

.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 追踪异步状态

const loadingRef = useRef(false)  // 同步状态,避免闭包陷阱
const requestIdRef = useRef<string | null>(null)  // 追踪请求 ID

useEffect 依赖清理

useEffect(() => {
  // 不要在 cleanup 中清空正在使用的 ref
  return () => {
    mountedRef.current = false
    // requestIdRef.current = null  // ❌ 不要清空,让异步请求正常完成
  }
}, [])

函数参数优先于状态

// ❌ 错误:使用可能过期的状态
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)
  • 提交申请时没有根据用户选择的审批人创建/更新节点

解决方案:

// 根据审批人创建审批节点
$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)

解决方案:

// 修改申请页面跳转逻辑
Toast.show(t('businessTrip.submitSuccess'))
navigate('/approval')  // 改为跳转到审批列表

相关文件:frontend/src/pages/business-trip/apply.tsx

23. 发起人自动审批通过逻辑

需求:当发起人选择的审批人中包含自己时,提交时自动标记为已审批

实现逻辑:

  1. 检查发起人是否在审批人列表中
  2. 如果是,自动创建审批通过记录
  3. 根据节点类型判断是否完成:
    • 单人审批(approver):发起人审批后节点完成
    • 或签(or_approve):发起人审批后节点完成
    • 会签(and_approve):需要所有审批人审批才完成
  4. 节点完成后移动到下一节点或完成审批

解决方案:

// 如果发起人在审批人列表中,自动标记为已审批
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. 飞书客户端内 - 静默授权

环境检测:

const ua = navigator.userAgent.toLowerCase()
const isInFeishu = ua.includes('feishu') || ua.includes('lark') ||
                   (window as any).ttjsBridge !== undefined

调用 ttjsBridge 获取授权码:

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 类型声明:
declare global {
  interface Window {
    ttjsBridge?: {
      getAuthCode: (params: any) => void
    }
  }
}
  1. 登录成功事件通知:
// 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)
  }
}, [])
  1. 二维码状态管理:
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'   // 红色 - 过期
  }
}

依赖安装

npm install qrcode.react

数据库迁移

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:

// 优先从 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() - 判断节点是否完成

// ❌ 旧代码:从 approval_node 获取审批人(错误)
$node = ApprovalNode::findOrFail($nodeId);
$approvers = $node->approvers ?? [];  // 静态模板数据,可能为空

// ✅ 新代码:从 form_data 获取审批人(正确)
$order = ApprovalOrder::find($orderId);
$formData = $order->form_data ?? [];
$approvers = $formData['approvers'] ?? [];  // 运行时数据,用户实际选择

ApprovalService::getPendingOrderIds() - 获取待审批订单

// ❌ 旧代码
$node = ApprovalNode::whereIn('id', $currentNodeIds)->first();
$approvers = $node->approvers ?? [];

// ✅ 新代码
$formData = $order->form_data ?? [];
$approvers = $formData['approvers'] ?? [];

兼容性处理:

// 兼容两种格式:[{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)不应该阻止流程完成,应该自动跳过

解决方案:

// 移动到下一节点
$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)
  • 没有外键约束确保引用完整性
  • 清理数据时可能遗留孤立记录

建议的修复方案

-- 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. 修复路由配置

// 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 生成逻辑

// 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

// backend/app/service/BusinessTripService.php
// 构建审批链接(统一使用审批详情页)
$approvalUrl = "https://xsm.dev.hnxinyouhe.com/approval/detail/{$approvalOrder->id}";

// 使用 $approvalUrl 而不是 $detailUrl
$notificationService->sendApprovalNotification([
    'url' => $approvalUrl,
    // ...
]);

4. 修复登录页登录状态检查

// 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(开发环境)

# 创建配置文件禁用 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. 数据库迁移

-- 添加 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 模型

// 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. 修改发送逻辑

// 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. 修改更新逻辑

// 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 方法

// 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文档: