本文目录导读:

在PHP项目中实现“流程加签”(即流程审批过程中,当前环节的处理人临时增加额外的审批人)是一个常见且复杂的需求,这需要结合工作流引擎(symfony/workflow、state-machine 或自研引擎)以及灵活的数据库设计来实现。
下面我将从数据库设计、核心逻辑、代码示例 和 注意事项 四个层面,为你提供一套完整的实现方案。
核心思路与数据库设计
加签的本质是:在不改变原始流程定义的情况下,动态地在当前节点插入一个或多个审批人,并要求他们处理完才能流转到下一节点。
数据库表设计(关键)
你需要设计至少两张核心表:
表1: wf_process_node (流程节点实例表)
记录一个审批单在某个节点的状态。
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
INT | 主键 |
process_instance_id |
INT | 流程实例ID |
node_id |
VARCHAR | 节点定义ID (如 "manager_approve") |
node_name |
VARCHAR | 节点名称 (如 "经理审批") |
status |
TINYINT | 0:待处理, 1:通过, 2:拒绝, 3:加签中 |
parent_id |
INT | 关键字段:父节点ID,如果是加签产生的子节点,此字段指向原始节点ID。 |
表2: wf_node_assignee (节点处理人表)
记录每个节点具体由谁处理。
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
INT | 主键 |
node_instance_id |
INT | 关联 wf_process_node.id |
user_id |
INT | 处理人ID |
status |
TINYINT | 0:待处理, 1:已处理, 2:跳过 |
type |
VARCHAR | 关键字段:区分 normal (正常) 还是 countersign (加签人) |
实现逻辑流程图
用户A发起申请 (状态: 经理审批 待处理)
|
v
经理B审批 -> 点击“加签”按钮
|
v
系统操作:
1. 创建子节点 (parent_id = 当前节点ID, status = 加签中)
2. 将当前节点状态改为“加签中”
3. 将部门经理C添加到 子节点的 assignee 表中
|
v
加签人C登录 -> 看到待办 -> 审批通过/拒绝
|
v
系统判断:该子节点下所有加签人是否处理完毕?
|
+--- 是:将父节点状态恢复为“待处理”,子节点状态变为“已完成”,经理B继续审批。
|
+--- 否:等待其他加签人。
PHP 核心代码实现示例 (ThinkPHP/Laravel 风格)
假设我们有 ProcessInstanceController 和 NodeService。
加签操作 (Controller 层)
<?php
namespace App\Http\Controllers;
use App\Services\NodeService;
use Illuminate\Http\Request;
class ApprovalController extends Controller
{
protected $nodeService;
public function __construct(NodeService $nodeService)
{
$this->nodeService = $nodeService;
}
/**
* 执行加签操作
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function addCounterSign(Request $request)
{
// 1. 参数校验
$request->validate([
'node_instance_id' => 'required|integer', // 当前审批节点实例ID
'user_ids' => 'required|array', // 要加签的多个用户ID
'user_ids.*' => 'integer',
]);
$nodeInstanceId = $request->input('node_instance_id');
$counterSignUserIds = $request->input('user_ids');
$currentUserId = auth()->id(); // 当前操作人 (经理B)
// 2. 调用服务层
$result = $this->nodeService->createCounterSignNode($nodeInstanceId, $currentUserId, $counterSignUserIds);
if ($result) {
return response()->json(['code' => 200, 'msg' => '加签成功']);
} else {
return response()->json(['code' => 400, 'msg' => '加签失败,可能无权限']);
}
}
}
核心服务层 (NodeService)
<?php
namespace App\Services;
use App\Models\ProcessNode;
use App\Models\NodeAssignee;
use DB;
class NodeService
{
/**
* 创建加签节点
* @param int $parentNodeInstanceId 当前节点实例ID (经理B的节点)
* @param int $currentUserId 当前操作人ID (经理B)
* @param array $counterSignUserIds 加签人ID数组 [C_id, D_id]
* @return bool
* @throws \Exception
*/
public function createCounterSignNode($parentNodeInstanceId, $currentUserId, $counterSignUserIds)
{
// 使用事务保证数据一致性
return DB::transaction(function () use ($parentNodeInstanceId, $currentUserId, $counterSignUserIds) {
// 1. 获取当前节点实例
$parentNode = ProcessNode::findOrFail($parentNodeInstanceId);
// 2. 权限校验:只有当前节点的处理人(且该节点状态为“待处理”)才能加签
$isAssignee = NodeAssignee::where('node_instance_id', $parentNodeInstanceId)
->where('user_id', $currentUserId)
->where('type', 'normal')
->exists();
if (!$isAssignee || $parentNode->status != 0) {
throw new \Exception('无操作权限或节点状态不正确');
}
// 3. 创建子节点(加签节点)
$counterSignNode = ProcessNode::create([
'process_instance_id' => $parentNode->process_instance_id,
'node_id' => $parentNode->node_id . '_countersign', // 节点ID加后缀区分
'node_name' => $parentNode->node_name . '(加签)',
'status' => 0, // 待处理
'parent_id' => $parentNode->id, // 指向父节点
]);
// 4. 将加签人添加到子节点的指派表
$assignees = [];
foreach ($counterSignUserIds as $userId) {
$assignees[] = [
'node_instance_id' => $counterSignNode->id,
'user_id' => $userId,
'status' => 0,
'type' => 'countersign', // 标记为加签人
];
}
NodeAssignee::insert($assignees);
// 5. 修改父节点状态为“加签中”
$parentNode->status = 3; // 3 = 加签中
$parentNode->save();
return true;
});
}
/**
* 处理加签节点的审批(加签人审批)
* @param int $nodeInstanceId 加签节点实例ID
* @param int $userId 加签人ID
* @param string $action approve / reject
* @return bool
*/
public function handleCounterSignApproval($nodeInstanceId, $userId, $action)
{
return DB::transaction(function () use ($nodeInstanceId, $userId, $action) {
// 1. 查询当前节点
$counterSignNode = ProcessNode::with('parent')->findOrFail($nodeInstanceId);
if ($counterSignNode->status != 0) {
throw new \Exception('节点已处理');
}
// 2. 更新该加签人的处理状态
$assignee = NodeAssignee::where('node_instance_id', $nodeInstanceId)
->where('user_id', $userId)
->where('type', 'countersign')
->firstOrFail();
$assignee->status = ($action == 'approve') ? 1 : 2; // 1通过,2拒绝
$assignee->save();
// 3. 检查该加签节点是否所有加签人都处理完毕
$pendingCount = NodeAssignee::where('node_instance_id', $nodeInstanceId)
->where('status', 0)
->count();
if ($pendingCount == 0) {
// 4. 所有加签人处理完毕
// 如果有任何一人拒绝,则整个节点视为拒绝(可根据业务调整:比如全部通过才算通过)
$rejectedCount = NodeAssignee::where('node_instance_id', $nodeInstanceId)
->where('status', 2)
->count();
if ($rejectedCount > 0) {
// 加签节点拒绝 -> 父节点也拒绝
$counterSignNode->status = 2; // 拒绝
$counterSignNode->parent->status = 2; // 父节点拒绝
} else {
// 加签节点通过 -> 恢复父节点为待处理
$counterSignNode->status = 1; // 通过
$counterSignNode->parent->status = 0; // 父节点恢复待处理
}
$counterSignNode->save();
$counterSignNode->parent->save();
}
return true;
});
}
}
查询待办列表(支持加签)
// 查询当前用户的待办
$userId = auth()->id();
$todos = NodeAssignee::where('user_id', $userId)
->where('status', 0)
->with(['node.processInstance']) // 关联节点和流程实例
->get();
// 注意:这里需要区分 normal 和 countersign
foreach ($todos as $todo) {
if ($todo->type == 'countersign') {
echo "这是一个加签待办,来自流程:{$todo->node->processInstance->name}";
} else {
echo "这是一个正常待办,来自流程:{$todo->node->processInstance->name}";
}
}
前端交互设计(关键用户体验)
加签操作在 UI 上需要有明确且简洁的指引:
- 显示加签按钮:
- 仅在当前节点状态为“待处理”时可见。
- 建议放在“审批通过/拒绝”旁边,作为“更多操作”下的选项(避免误点)。
- 选择加签人:
- 弹窗或下拉菜单选择用户(注意排除当前审批人,避免逻辑循环)。
- 状态提示:
- 在“我的申请”和“待办列表”中,清晰显示当前节点是“等待A审批”,还是“等待A(加签中,等待B和C审批)”。
- 可以在节点名称旁加一个徽章
[加签]。
需要注意的复杂场景与经验
- 节点的“或签”与“会签”:
- 上述代码默认是“会签”(所有加签人都处理完才算完成),如果需要“或签”(一人通过即可),修改
NodeService中第3步的检查逻辑即可。
- 上述代码默认是“会签”(所有加签人都处理完才算完成),如果需要“或签”(一人通过即可),修改
- 加签层级限制:
- 业务上通常不允许无限加签(比如A加签B,B加签C,C加签D...),否则流程会非常混乱,建议在
createCounterSignNode中增加层级判断:parent->parent_id != null(已经有加签记录),则禁止再加签。
- 业务上通常不允许无限加签(比如A加签B,B加签C,C加签D...),否则流程会非常混乱,建议在
- 审批历史视图:
- 你的审批记录查询SQL需要能正确处理
parent_id的关系,最好使用JOIN或递归查询将父子节点合并展示,让用户能看清单:“经理B审批”,随后“经理B加签了C”,C通过,B继续审批通过”。
- 你的审批记录查询SQL需要能正确处理
- 性能考虑:
- 上述代码使用单表
ProcessNode和NodeAssignee适用大多数场景,如果流程实例数量非常大(百万级),建议考虑将节点状态缓存到 Redis 或用更成熟的流程引擎(如Camunda BPM的PHP客户端)。
- 上述代码使用单表
流程加签在PHP中的核心实现就是 “创建子节点 + 改变父节点状态 + 子节点审批完恢复父节点”,关键在于处理好:
- 数据库的
parent_id和type字段。 - 清晰的权限校验(仅当前处理人可加签)。
- 节点状态切换的原子性(使用事务)。
- 前端清晰的交互提示。
按照上述方案,你可以在不依赖复杂外部组件的情况下,快速实现一个稳定、可维护的加签功能。