从头做一个审批模块

最近接到了一个『审批』模块的需求,大概就是某某某申请加入某某项目、某某某的报销申请等待您的审批这样。这篇文章给大家讲述了我本次设计和开发这个功能的心路历程,可能没有各位大佬写的文章那么详细和深入,只是简单描述了我的思路和实现步骤,供各位参考。

具体的需求场景

  • 允许用户申请加入团队或者项目
  • 申请之后系统推送审批通知给对应的管理员
  • 管理员可以对申请进行审批:通过或者拒绝
  • 审批通过则申请者顺利加入团队/项目中,否则申请无效
  • 审批结果推送给管理员以及申请者

大致的流程如下图:

image-20201127234542511

接下来我将从 『数据表设计』 和 『程序设计』 两个方面进行阐述:

数据表设计

1. 确定表名

第一步确定一下表名,『申请』。

说到数据表的命名,我觉得也是一门学问,不单单是数据表的命名,但凡涉及到命名的就是一门高深的学问,往往有的时候命名的时间,比我写一个方法函数的时间还要长,无奈一直找不到精髓所在。

我第一个想到的就是 applications ,有一个书面申请的含义在,也是个名词,可惜这个单词在我们数据库中已经被占用,作为『应用』表了。所以思来想去最后选择了 apply,转为复数 applies

2. 确定字段

表名确定了,我们来一个个步骤进行分析,确定最终的数据表字段:

  • 提交申请

    单从字面上来说,我们会有三个疑问:

    1. 谁申请的?

      顾名思义,也就是这个申请的发起人, creator_id

      但是有的时候需求方并不单单只是用户,有可能是一个公司,也有可能是一个外部应用。所以这个需求方,可以定义为多态的,说的通俗一点就是通过类型和 ID 来决定对象。不过这里我并没有定义为多态,因为应用里面不会出现需求方不确定类型的场景,还是继续沿用 creator_id

    2. 申请什么?

      从上面的需求场景中我们可以看出,被申请的对象可能是团队也可能是项目,也就是被申请对象不确定,和上面的不确定的需求方是等同的;且一个被申请对象可以被不同的需求方申请多次,也就是典型的 一对多多态关联

      所以我们可以增加两个字段 target_typetarget_id

      target_type 对应着申请对象的类型,像上面的 teamsprojects ;而 target_id 就是对应的 对象 id

    3. 申请目的?

      就目前需求场景,其申请目的其实就是希望成为团队或者项目中的一员。当然他可以有更多其他的目的,比如说申请一份项目资料、申请团队经费报销等等,所以我们可以抽象出来一个字段 action ,也就是审批通过之后会执行的动作。

      • 申请加入:action = join
      • 申请报销:action = reimburse
  • 管理员审批

    同样的,我们也会出现几个疑问:

    1. 是谁审批的?

      reviewer_id

    2. 什么时候审批的?

      reviewed_at

    3. 审批的结果是什么?

      status

      • 待审批:status = pending
      • 已通过:status = passed
      • 已拒绝:status = rejected
      • 已取消:status = canceled

      至于为什么用过去分词,那是因为存到库里面的时候说明这个动作已经发生了。

    4. 这样审批的理由是什么?

      reason

      有时候拒绝了,备注个理由,申请者就可以清晰的明白为什么。

  • 通知(申请通知、审批结果通知)

    大部分 web 应用应该都有通知表,这里无非是多加了一个 审批 类型的通知,所以通知表的设计就不在这里提了。

有时候不乏一些定制化的需求,我们可能在某些申请的时候还会附带一些额外的信息。比如申请报销的时候可能会附带报销单的信息用于展示,于是加了一个扩展字段 payload

至此我们申请的数据表就建立完毕了,我们来看看成品:

image-20201127234626079

程序设计

数据表建完了,接下来我们一起来看一下,在程序上我是怎么设计的。下面的示例代码将以 PHP 语言进行编写,使用的框架为 Laravel

1. 建立模型类

根据上面设计好的数据表,我们对 Apply 进行建模

<?php

namespace App;

...
use Illuminate\Database\Eloquent\Model;
...

class Apply extends Model
{
    ...
      /**
     * @var array
     */
    protected $fillable = [
        'creator_id', 'status', 'reviewer_id', 'reviewed_at', 'reason',
        'target_id', 'target_type', 'payload', 'action',
    ];

    /**
     * @var array
     */
    protected $casts = [
        'creator_id' => 'int',
        'reviewer_id' => 'int',
        'payload' => 'array',
    ];

    /**
     * @var array
     */
    protected $dates = [
        'reviewed_at',
    ];
    ...

    /**
     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
     */
    public function target()
    {
        return $this->morphTo('target');
    }
}

2. 定义 Trait

团队和项目都可以成为被申请的主体,有可能更多,为了减少代码的重复量,我们不妨利用 Trait 来帮我们实现,在其中定义了一个获取当前模型作为被申请对象的所有申请的方法。

<?php

namespace App\Traits;

use App\Apply;

/**
 * Trait CanBeApplied.
 */
trait CanBeApplied
{
    /**
     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
     */
    public function applies()
    {
        return $this->morphMany(Apply::class, 'target');
    }
}

Tip:对其关联写法不熟悉的,可以对照文档再仔细看看。

3. 接口设计

我们先确定一下接口应该有哪些、参数应该有哪些、分别干哪些事情。

  • 用户提交申请接口

    参数 A:申请类型:target_type

    参数 B:申请类型 ID :target_id

    参数 C:申请干什么?action

    参数 D:谁申请的?creator_id ,正常情况下,这个数据直接取当前登录用户,不需要单独接收这个参数了。

    public function store (
        $targetType,
        $targetId,
        $action = 'join',
        $creatorId = auth()->id()
    ) {
        ...
    }
  • 管理员审批接口

    参数 A:申请对象:Apply

    参数 B:审批状态:通过、拒绝申请:status

    参数 C:通过理由、拒绝理由:reason

    参数 D:谁审批的?同上面的一样,可以直接取当前登录用:reviewer_id

    public function review {
        Apply $apply,
        $status = 'passed',
        $reason = '',
        $reviewerId = auth()->id()
    ) {
        ...
    }
    
    // 拆分为两个接口
    // 审批通过
    public function passed {
        Apply $apply,
        $creatorId = auth()->id()
    ) {
       ...
    }
    
    // 拒绝申请
    public function rejected {
        Apply $apply,
        $reason = '',
        $creatorId = auth()->id()
    ) {
        ...
    }

    上面的接口和参数一目了然了,但是显然很不 Laravel 。现在我们以 Laravel 应该有的姿势来编写:

    <?php
    
    namespace App\Http\Controllers;
    
    use App\Apply;
    use App\Http\Resources\Resource;
    use App\Rules\In;
    use App\Rules\Polymorphic;
    use Illuminate\Http\Request;
    
    class ApplyController extends Controller
    {
        /**
         * @param \Illuminate\Http\Request $request
         *
         * @return \App\Http\Resources\Resource
         *
         * @throws \Illuminate\Auth\Access\AuthorizationException
         * @throws \Illuminate\Validation\ValidationException
         */
        public function store(Request $request)
        {
            $this->authorize('create', Apply::class);
    
            $this->validate($request, [
              'action' => [
                'string', new In(['join', ...]),
              ],
              'target_id' => [
                'required', new Polymorphic('target', '未指定申请主体或申请主体不存在'),
              ],
            ]);
    
            return new Resource($request->target(true)->applies()->create());
        }
    
        /**
         * @param \Illuminate\Http\Request $request
         * @param \App\Apply               $apply
         *
         * @return \Illuminate\Http\Response
         *
         * @throws \Illuminate\Auth\Access\AuthorizationException
         */
        public function approve(Request $request, Apply $apply)
        {
            $this->authorize('review', $apply);
    
            $apply->markAsPassed();
    
            return \response()->noContent();
        }
    
        /**
         * @param \Illuminate\Http\Request $request
         * @param \App\Apply               $apply
         *
         * @return \Illuminate\Http\Response
         *
         * @throws \Illuminate\Auth\Access\AuthorizationException
         */
        public function reject(Request $request, Apply $apply)
        {
            $this->authorize('review', $apply);
    
            $apply->markAsRejected();
    
            return \response()->noContent();
        }
    }

    对多态关系的表单验证不太清楚的可以戳这里:『Laravel 中多态关系的表单验证』 。$request->target(true) 有疑问的也可以点链接进去看一下,这里就不展开讲了。

    至于 markAsPassedmarkAsRejected 方法只是把状态更新的操作放到 Apply 模型里面而已,鉴权的在文档里面也能找到对应的写法。

4. 申请事务处理

事务处理,处理什么呢?审批通过则根据用户的申请动作做出相应的处理;审批不通过则啥都不干发送通知就行了。

就目前的需求场景也就是将申请者加入到对应的项目或者团队中。换做以前的我,或者现在的大部分人可能会这么来干:

...
public function passed(Request $request) {
    ...
    $apply->markAsPassed();
    $apply->target->users()->syncWithoutDetaching($apply->creator_id);
    ...
}
....

这么干也无可厚非,直观明了、粗暴干净。但是有个问题,如果申请的并不是加入到团队呢?这个时候,各种 ifelseswitch 就全跑出来了。秉承着 Laravel 优雅的原则,我打算这么干:

<?php

namespace App;

...
use Illuminate\Database\Eloquent\Model;
...

class Apply extends Model
{
    ...

    protected static function boot()
    {
        parent::boot();

        static::updating(function (Apply $apply) {
            if ($apply->isDirty('status') && \auth()->check()) {
                $apply->reviewer_id = \auth()->id();
                $apply->reviewed_at = \now();
            }
        });

        static::updated(function (Apply $apply) {
            if ($apply->isDirty('status')) {
                \event(new ApplyReviewed($apply), [], true);
            }
        });
    }

      ...
}

使用 事件系统 来进行处理,利用 updatingupdated 模型事件,监听 status 的状态变化,触发 ApplyReviewed 事件,然后事件里面的处理无非就是根据不同的 Action 不同的 Target 进行不同的事务处理。

5. 通知

Tip:这里不是讲解怎么去实现 通知 功能的,而是讲述怎么去调用通知,以及怎么展示审批通知。

从需求场景中,我们不难发现有两处地方涉及到发送通知,一个是需求方发送申请的时候,审批通知推送给对应的管理员,还有一个是处理完申请之后,结果推送给管理员。

看到这里是不是感觉可以把这部分的处理逻辑放在上面的 模型事件 中了:

<?php

  ...
  protected static function boot()
    {
        parent::boot();

            static::created(function (Apply $apply) {
            \event(new ApplyCreated($apply), [], true);
        });

        ...

        static::updated(function (Apply $apply) {
            if ($apply->isDirty('status')) {
                \event(new ApplyReviewed($apply), [], true);
            }
        });
    }
  ...

申请创建的审批通知推送可以在 ApplyCreated 事件里面进行处理。

处理完审批之后通知推送逻辑可以直接基于 ApplyReviewed 事件,创建新的 Listener,或者在同一个 Listener 中进行任务分发处理(DispatchJob)。

上面的内容其实跟 申请事务处理 的设计是一样样的,至于为什么把 通知 单独出来讲主要是为了以下程序的设计。

  • 审批通知列表

在需求方发送申请之后,其对应的管理员的审批列表该如何呈现呢?

本来是打算直接给 ApplyController 加一个列表接口,但是发现了一个问题:申请的类型多样化,能够审批的人也会有多个。如果说直接取 applies 表中的数据进行展示的话,那得一条条数据进行遍历,判断当前用户是否可以看到本条申请……这无疑太狗血了,只能将 审批通知 当作申请列表来进行展示了,因为在通知分发的时候就已经可以确定这个收到的人是有权限处理的。

所以在申请列表那一栏里面,展示的是审批通知列表,但是这样的话还是会出现一个问题:当某个申请被审批了之后,通知内容里面的状态是没有变更的,依旧是初始状态,为了解决这个问题,我想过当审批之后,批量更新对应的通知记录,更改里面的状态值。

还没想完,反手就是一巴掌,既然是通知,就相当于一条静态的数据了,哪有给发出去的通知改内容的。

所以在审批通知列表加载的时候,遍历了一下,对输出的审批通知进行了状态更新。估摸着还会有更优解,欢迎

大家一起来讨论。

结束语

以上就是我在设计和开发 审批模块 的所思和所想,希望能够给大家多多少少带来一点帮助。可能流程不是那么的规范,如果有更好的设计模式和流程,希望大家能够在评论区留言讨论。

再会!

本作品采用《CC 协议》,转载必须注明作者和本文链接
finecho # Lhao
本帖由系统于 9个月前 自动加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 15

如果是我, 并且想怎么写就怎么写的话, 我会这么写

先定义接口

namespace App\Repositories\Contracts;

interface ApplyInterface
{
    /**
     * 构建一个审批实体
     * @param  $user 申请人
     * @param  $subject 申请名称
     * @param  $description 申请描述
     * @param  $reviewer 审批人
     * @param  $callback 申请通过的后置操作
     * @param  $callbackData 后置操作需要的数据
     * @return ApplyModel
     */
    public function build($user, $subject, $description, $reviewer, $callback, $callbackData=null):ApplyModel;

    /**
     * 提交一个审批
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function submit(ApplyModel $apply):ApplyModel;

    /**
     * 同意一个审批
     * @param  ApplyModel $apply
     * @param  string $note 审批的时候可以写点意见
     * @return ApplyModel
     */
    public function agree(ApplyModel $apply, $note=''):ApplyModel;

    /**
     * 拒绝一个审批
     * @param  ApplyModel $apply
     * @param  string $note 审批的时候可以写点意见
     * @return ApplyModel $apply
     */
    public function refuse(ApplyModel $apply, $note=''):ApplyModel;

    /**
     * 不管是通过了还是被拒绝了, 申请人都需要对审批结果进行确认
     * @param  ApplyModel $apply
     * @param  string $note 不服也可以写意见
     * @return ApplyModel
     */
    public function confirm(ApplyModel $apply, $note=''):ApplyModel;

    /**
     * 如果在审批前感觉不对, 那么可以取消
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function cancel(ApplyModel $apply):ApplyModel;

    /**
     * 申请人可以修改一下重新提交
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function update(ApplyModel $apply, $option):ApplyModel;


    /**
     * 重新提交一个审批
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function retry(ApplyModel $apply):ApplyModel;


    /**
     * 可以查询和自己有关的审批
     * @param  $role 角色
     * @param  $status 状态
     * @return ApplyModel
     */
    public function query($role=null, $status=null):ApplyModel;
}

interface ApplyActionFactory
{
    public function run($apply, $data=null);
}

然后定义模型

namespace App\Model;


/**
 * id
 * hash_id
 * user_type
 * user_id
 * subject
 * description
 * callback
 * callbackData
 * status
 * create_at
 * update_at
 * delete_at
 */
class ApplyModel
{

}
/**
 * user_type 
 * user_id
 * apply_id
 * role
 * note
 * result
 */
class ApplyUserModel
{

}

然后定义枚举

enum ApplyStatusEnum:int
{
    // 还没有提交
    case incomplete = 0;
    // 已经走完流程
    case completed = 1;
    // 待审批
    case pending = 2;
    // 已通过
    case passed = 3;
    // 已拒绝
    case rejected = 4;
    // 已取消
    case canceled = 5;
}

enum ApplyVerifyStatusEnum:int
{
    case base = 0;
    case agree = 1;
    case refuse = 2;
}

enum ApplyRoleEnum:int
{
    case reviewer = 1;
    case creator = 2;
}

然后实现接口

namespace App\Repositories\Service;

class ApplyManager implements ApplyInterface
{

    private $user;
    private $reviewer;

    // 这里实现一个方法, 说明一下大概的思路
    public function build($user, $subject, $description, $reviewer, $callback, $callbackData=null)
    {
        $this->user = $user;
        $this->reviewer = $reviewer;
        $this->apply = ApplyModel::create([
            'user_id' => $user->id,
            'user_type' => $user->user_type,
            'subject' => $subject,
            'description' => $description,
            'callback' => $callback,
            'callbackData' => $callbackData,
            'status' => ApplyStatusEnum::incomplete
        ]);
        $this->distribute();
        return $this->apply;
    }

    private function distribute()
    {
        $data = [$this->formatUser($this->user, ApplyRoleEnum::creator)];
        if(is_array($this->reviewer)){
            for($i=0; $i< count($this->reviewer); $i++){
                $data[] = $this->formatUser($this->reviewer[$i], ApplyRoleEnum::reviewer);
            }
        }else{
            $data[] =  $this->formatUser($this->reviewer, ApplyRoleEnum::reviewer);
        }

        ApplyUserModel::create($data);
    }

    private function formatUser($user, $role)
    {
        return  [
            'user_type' => get_class($user),
            'user_id' => $user->id,
            'apply_id' =>  $this->apply->id,
            'role' => $role,
        ];
    }
}

实现一种审批后置操作

namespace App\Repositories\Actions;

class ApplyDailyAction implements ApplyActionFactory
{
    // 假设这里日报通过之后要发到公司群给所有人看
    public function run(ApplyModel $apply, $data=null)
    {
        return ReportDaily::dispatch($apply);
    }
}

控制器逻辑

namespace App\Controller;

class ApplyController
{
    public function store(Request $request){
        ···
        return new ApplyResource(
            ApplyManager::build(
                Auth::user(), 
                '我觉的可以', 
                '我觉得不行', 
                User::find($request->reviewerId), 
                ApplyDailyAction::class
            )
        );
    }

    public function send(Request $request){
        return new ApplyResource(ApplyManager::submit($request->id))
    }
}

em… 不能忘了写 test

class ApplyTest{

}
9个月前 评论
徵羽宫 (作者) 9个月前

如果是我, 并且想怎么写就怎么写的话, 我会这么写

先定义接口

namespace App\Repositories\Contracts;

interface ApplyInterface
{
    /**
     * 构建一个审批实体
     * @param  $user 申请人
     * @param  $subject 申请名称
     * @param  $description 申请描述
     * @param  $reviewer 审批人
     * @param  $callback 申请通过的后置操作
     * @param  $callbackData 后置操作需要的数据
     * @return ApplyModel
     */
    public function build($user, $subject, $description, $reviewer, $callback, $callbackData=null):ApplyModel;

    /**
     * 提交一个审批
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function submit(ApplyModel $apply):ApplyModel;

    /**
     * 同意一个审批
     * @param  ApplyModel $apply
     * @param  string $note 审批的时候可以写点意见
     * @return ApplyModel
     */
    public function agree(ApplyModel $apply, $note=''):ApplyModel;

    /**
     * 拒绝一个审批
     * @param  ApplyModel $apply
     * @param  string $note 审批的时候可以写点意见
     * @return ApplyModel $apply
     */
    public function refuse(ApplyModel $apply, $note=''):ApplyModel;

    /**
     * 不管是通过了还是被拒绝了, 申请人都需要对审批结果进行确认
     * @param  ApplyModel $apply
     * @param  string $note 不服也可以写意见
     * @return ApplyModel
     */
    public function confirm(ApplyModel $apply, $note=''):ApplyModel;

    /**
     * 如果在审批前感觉不对, 那么可以取消
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function cancel(ApplyModel $apply):ApplyModel;

    /**
     * 申请人可以修改一下重新提交
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function update(ApplyModel $apply, $option):ApplyModel;


    /**
     * 重新提交一个审批
     * @param  ApplyModel $apply
     * @return ApplyModel
     */
    public function retry(ApplyModel $apply):ApplyModel;


    /**
     * 可以查询和自己有关的审批
     * @param  $role 角色
     * @param  $status 状态
     * @return ApplyModel
     */
    public function query($role=null, $status=null):ApplyModel;
}

interface ApplyActionFactory
{
    public function run($apply, $data=null);
}

然后定义模型

namespace App\Model;


/**
 * id
 * hash_id
 * user_type
 * user_id
 * subject
 * description
 * callback
 * callbackData
 * status
 * create_at
 * update_at
 * delete_at
 */
class ApplyModel
{

}
/**
 * user_type 
 * user_id
 * apply_id
 * role
 * note
 * result
 */
class ApplyUserModel
{

}

然后定义枚举

enum ApplyStatusEnum:int
{
    // 还没有提交
    case incomplete = 0;
    // 已经走完流程
    case completed = 1;
    // 待审批
    case pending = 2;
    // 已通过
    case passed = 3;
    // 已拒绝
    case rejected = 4;
    // 已取消
    case canceled = 5;
}

enum ApplyVerifyStatusEnum:int
{
    case base = 0;
    case agree = 1;
    case refuse = 2;
}

enum ApplyRoleEnum:int
{
    case reviewer = 1;
    case creator = 2;
}

然后实现接口

namespace App\Repositories\Service;

class ApplyManager implements ApplyInterface
{

    private $user;
    private $reviewer;

    // 这里实现一个方法, 说明一下大概的思路
    public function build($user, $subject, $description, $reviewer, $callback, $callbackData=null)
    {
        $this->user = $user;
        $this->reviewer = $reviewer;
        $this->apply = ApplyModel::create([
            'user_id' => $user->id,
            'user_type' => $user->user_type,
            'subject' => $subject,
            'description' => $description,
            'callback' => $callback,
            'callbackData' => $callbackData,
            'status' => ApplyStatusEnum::incomplete
        ]);
        $this->distribute();
        return $this->apply;
    }

    private function distribute()
    {
        $data = [$this->formatUser($this->user, ApplyRoleEnum::creator)];
        if(is_array($this->reviewer)){
            for($i=0; $i< count($this->reviewer); $i++){
                $data[] = $this->formatUser($this->reviewer[$i], ApplyRoleEnum::reviewer);
            }
        }else{
            $data[] =  $this->formatUser($this->reviewer, ApplyRoleEnum::reviewer);
        }

        ApplyUserModel::create($data);
    }

    private function formatUser($user, $role)
    {
        return  [
            'user_type' => get_class($user),
            'user_id' => $user->id,
            'apply_id' =>  $this->apply->id,
            'role' => $role,
        ];
    }
}

实现一种审批后置操作

namespace App\Repositories\Actions;

class ApplyDailyAction implements ApplyActionFactory
{
    // 假设这里日报通过之后要发到公司群给所有人看
    public function run(ApplyModel $apply, $data=null)
    {
        return ReportDaily::dispatch($apply);
    }
}

控制器逻辑

namespace App\Controller;

class ApplyController
{
    public function store(Request $request){
        ···
        return new ApplyResource(
            ApplyManager::build(
                Auth::user(), 
                '我觉的可以', 
                '我觉得不行', 
                User::find($request->reviewerId), 
                ApplyDailyAction::class
            )
        );
    }

    public function send(Request $request){
        return new ApplyResource(ApplyManager::submit($request->id))
    }
}

em… 不能忘了写 test

class ApplyTest{

}
9个月前 评论
徵羽宫 (作者) 9个月前

据说有个叫业务流引擎的东西,还没研究

9个月前 评论
抄你码科技有限公司 9个月前
liziyu 9个月前
liziyu 9个月前
抄你码科技有限公司 9个月前
liziyu 9个月前
porygonCN 7个月前

你们都太优秀了。 :+1:

9个月前 评论

审批流涉及到的东西其实还挺多的,想要找这方面的资料可以使用关键字BPMN作为搜索, 审批流一共可以分为两大模块"流程的绘制"和"流程的流转", 流程的绘制可以参考这个开源项目github.com/StavinLi/Workflow, 审批流的设计可以使用掘金的这篇文章作为参考juejin.cn/post/6925800587229560846, 审批流数据库的设计可以参考这篇文章juejin.cn/post/7054174792437465124, 最终的实现效果其实是这样子的

file

file

9个月前 评论

刚写完一个业务公司内部OA审批流系统,包括了微信模板消息推送..

9个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!