最近接到了一个『审批』模块的需求,大概就是某某某申请加入某某项目、某某某的报销申请等待您的审批这样。这篇文章给大家讲述了我本次设计和开发这个功能的心路历程,可能没有各位大佬写的文章那么详细和深入,只是简单描述了我的思路和实现步骤,供各位参考。
具体的需求场景
•允许用户申请加入团队或者项目•申请之后系统推送审批通知给对应的管理员•管理员可以对申请进行审批:通过或者拒绝•审批通过则申请者顺利加入团队/项目中,否则申请无效•审批结果推送给管理员以及申请者
大致的流程如下图:
接下来我将从 『数据表设计』 和 『程序设计』 两个方面进行阐述:
数据表设计
确定表名
第一步确定一下表名,『申请』。
说到数据表的命名,我觉得也是一门学问,不单单是数据表的命名,但凡涉及到命名的就是一门高深的学问,往往有的时候命名的时间,比我写一个方法函数的时间还要长,无奈一直找不到精髓所在。
我第一个想到的就是 applications
,有一个书面申请的含义在,也是个名词,可惜这个单词在我们数据库中已经被占用,作为『应用』表了。所以思来想去最后选择了 apply
,转为复数 applies
。
确定字段
表名确定了,我们来一个个步骤进行分析,确定最终的数据表字段:
提交申请
单从字面上来说,我们会有三个疑问:
1.谁申请的?
顾名思义,也就是这个申请的发起人, creator_id
。
但是有的时候需求方并不单单只是用户,有可能是一个公司,也有可能是一个外部应用。所以这个需求方,可以定义为多态的,说的通俗一点就是通过类型和 ID 来决定对象。不过这里我并没有定义为多态,因为应用里面不会出现需求方不确定类型的场景,还是继续沿用 creator_id
。
2.申请什么?
从上面的需求场景中我们可以看出,被申请的对象可能是团队也可能是项目,也就是被申请对象不确定,和上面的不确定的需求方是等同的;且一个被申请对象可以被不同的需求方申请多次,也就是典型的 一对多多态关联[1]。
所以我们可以增加两个字段 target_type
、target_id
。
target_type
对应着申请对象的类型,像上面的 teams
、projects
;而 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
。
至此我们申请的数据表就建立完毕了,我们来看看成品:
程序设计
数据表建完了,接下来我们一起来看一下,在程序上我是怎么设计的。下面的示例代码将以 PHP
语言进行编写,使用的框架为 Laravel[2]。
建立模型类
根据上面设计好的数据表,我们对 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'); }}
定义 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:对其关联写法不熟悉的,可以对照文档再仔细看看。
接口设计
我们先确定一下接口应该有哪些、参数应该有哪些、分别干哪些事情。
用户提交申请接口
•参数 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 中多态关系的表单验证[3]』 。$request->target(true)[4] 有疑问的也可以点链接进去看一下,这里就不展开讲了。
至于 markAsPassed
和 markAsRejected
方法只是把状态更新的操作放到 Apply
模型里面而已,鉴权的在文档里面也能找到对应的写法。
申请事务处理
事务处理,处理什么呢?审批通过则根据用户的申请动作做出相应的处理;审批不通过则啥都不干发送通知就行了。就目前的需求场景也就是将申请者加入到对应的项目或者团队中。换做以前的我,或者现在的大部分人可能会这么来干:
...public function passed(Request $request) { ... $apply->markAsPassed(); $apply->target->users()->syncWithoutDetaching($apply->creator_id); ...}....
这么干也无可厚非,直观明了、粗暴干净。但是有个问题,如果申请的并不是加入到团队呢?这个时候,各种 if
、else
、switch
就全跑出来了。秉承着 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); } }); } ...}
使用 事件系统[5] 来进行处理,利用 updating
和 updated
模型事件[6],监听 status
的状态变化,触发 ApplyReviewed
事件,然后事件里面的处理无非就是根据不同的 Action
不同的 Target
进行不同的事务处理。
通知
Tips:这里不是讲解怎么去实现
通知
功能的,而是讲述怎么去调用通知,以及怎么展示审批通知。
从需求场景中,我们不难发现有两处地方涉及到发送通知,一个是需求方发送申请的时候,审批通知推送给对应的管理员,还有一个是处理完申请之后,结果推送给管理员。
看到这里是不是感觉可以把这部分的处理逻辑放在上面的 模型事件
中了:
<?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); } }); } ...
申请创建的审批通知推送可以在 Apply
的 Created
事件里面进行处理。处理完审批之后通知推送逻辑可以直接基于 ApplyReviewed
事件,创建新的 Listener
,或者在同一个 Listener
中进行任务分发处理(Dispatch
、Job
)。
上面的内容其实跟 申请事务处理
的设计是一样样的,至于为什么把 通知
单独出来讲主要是为了以下程序的设计。
审批通知列表
在需求方发送申请之后,其对应的管理员的审批列表该如何呈现呢?
本来是打算直接给 ApplyController
加一个列表接口,但是发现了一个问题:申请的类型多样化,能够审批的人也会有多个。如果说直接取 applies
表中的数据进行展示的话,那得一条条数据进行遍历,判断当前用户是否可以看到本条申请.....,这无疑太狗血了,只能将 审批通知
当作申请列表来进行展示了,因为在通知分发的时候就已经可以确定这个收到的人是有权限处理的。
所以在申请列表那一栏里面,展示的是审批通知列表,但是这样的话还是会出现一个问题:当某个申请被审批了之后,通知内容里面的状态是没有变更的,依旧是初始状态,为了解决这个问题,我想过当审批之后,批量更新对应的通知记录,更改里面的状态值。
还没想完,反手就是一巴掌,既然是通知,就相当于一条静态的数据了,哪有给发出去的通知改内容的。所以在审批通知列表加载的时候,遍历了一下,对输出的审批通知进行了状态更新。估摸着还会有更优解,欢迎大家一起来讨论。
结束语
以上就是我在设计和开发 审批模块
的所思和所想,希望能够给大家多多少少带来一点帮助。可能流程不是那么的规范,如果有更好的设计模式和流程,希望大家能够在评论区留言讨论。
将近一年半的时间没有更新博客了,这次在超哥的建议下重新捡了起来,希望能够一直坚持下去。也将自己从超哥身上学到的东西分享给大家,毕竟和超哥共事是很多人梦寐以求的,哈哈。
再会!
References
[1]
一对多多态关联: https://learnku.com/docs/laravel/8.x/eloquent-relationships/9407#one-to-many-polymorphic-relations[2]
Laravel: https://laravel.com/[3]
Laravel 中多态关系的表单验证: https://learnku.com/articles/12449/form-validation-of-polymorphic-relationships-in-laravel[4]
$request->target(true): https://learnku.com/articles/21754[5]
事件系统: https://learnku.com/docs/laravel/8.x/events/9391[6]
模型事件: https://learnku.com/articles/22742