简介:这是一套面向企业、机关单位的办公审批小程序完整源码包,覆盖请假、报销、外出、合同、采购、入职等多种审批流程,内置管理员、员工、审批人员三类角色,审批结果可通过微信消息提醒实时送达申请者,适用于快速搭建移动办公审批场景。资源共489个文件,压缩包约2.12MB,以189个js逻辑脚本、103个wxss样式、85个wxml页面、78个json配置为主,并配有png/jpg/gif图标素材、docx安装使用手册、xlsx表格及md说明文档,目录结构清晰,便于定位和二次开发。当前已有47人学习下载,包内含二维码生成库、图表库、数据库工具封装和页面辅助脚本,可直接参考消息通知配置、角色权限划分与审批流程跳转逻辑,也适合作为企业微信小程序开发的学习范例。
1. 企业办公审批小程序:当请假还在满楼找签字,流程已经可以这样跑
你是那个三天两头请假、报销的人,就一定见过这样的画面:申请单打印出来,跑两层楼找主管签字,人不在就贴在工位上干等;月底报销单攒了一摞,最后卡在“老板出差”这一环;合同要会签,采购要走部门经理再到财务,每一步都是“在途”,没人知道卡在哪。办公审批小程序要解决的就是这一类脏活——把请假审批、报销审批、外出审批、合同审批、采购审批、入离职审批这些规则固化成一套可配置的流程,让发起、审批、抄送、归档都在手机里一次完成,让流程跑到人能看见的地方,不给“在途”留黑匣子。它的价值不在“无纸化”这三个字,而在于每一单当前在哪个节点、谁在办、多久没办,随时能查。这套东西适合没有专职流程系统的小公司、多层级企业,以及需要审批留痕的机关部门;也适合已经有核心系统、想单独把审批外挂出去的团队。下面按“数据模型 → 流程配置 → 小程序端 → 避坑 → 进阶”的顺序,给你一条能落地的路径。
2. 审批数据模型:四张表加一张 JSON 表单,撑起整个审批流
2.1 为什么要拆成“模板-实例-任务”三层,而不是一个表单走天下
请假和报销最大的区别不是字段不一样,而是流程不一样:请假过了直属主管基本就结束,报销过了主管还得再走财务,合同还要法务会签。如果每个审批类型单独建一套表和接口,每加一个场景就改一遍代码,撑不到第五个就得返工。把审批抽象成“模板-实例-任务”三层之后,新增一个审批类型只是往模板里加一条配置,代码一行不用动。
模板负责描述“长什么样、怎么流转”,实例是用户发起后生成的一张具体单据,任务则是这张单据上待处理的一个个节点。表单字段、节点规则全部跟着模板走,实例存实际填写的数据和当前状态,任务记录每个操作人的处理结果和时间,这样设计才能应对请假、报销、外出、合同、采购这些流程结构差异很大的场景。
我在做这类系统时一般还会加一层“表单数据”与“流程数据”的分开存储:表单数据放在实例的 JSON 字段里,流程的走向由模板里的规则计算出来,这样新增一个审批项时不用跑数据库迁移。好处是上手快,代价是 JSON 字段无法直接 SQL 聚合统计,所以统计报表要走单独的归档表或用脚本定期导出。
2.2 最小建表:模板、实例、任务根上的“两个状态字段”
下面这套建表语句是常见做法里最精简的一套,实际项目里我都会在此基础上扩展,核心结构先立住再往上加字段:
CREATE TABLE dept ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, parent_id BIGINT DEFAULT 0, manager_id BIGINT DEFAULT NULL, status TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, dept_id BIGINT NOT NULL, job_title VARCHAR(50) DEFAULT '', open_id VARCHAR(64) DEFAULT '', status TINYINT DEFAULT 1 ); CREATE TABLE approval_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL, name VARCHAR(50) NOT NULL, form_schema JSON NOT NULL, flow_config JSON NOT NULL, status TINYINT DEFAULT 1, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE approval_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, template_code VARCHAR(32) NOT NULL, title VARCHAR(200) NOT NULL, applicant_id BIGINT NOT NULL, form_data JSON NOT NULL, current_node VARCHAR(50) NOT NULL, status TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE approval_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_key VARCHAR(50) NOT NULL, node_name VARCHAR(50) NOT NULL, assignee_ids VARCHAR(200) NOT NULL, assignee_names VARCHAR(500) NOT NULL, status TINYINT NOT NULL DEFAULT 0, comment TEXT, action_at DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里有两个字段一定要刻意保留:approval_instance.version用于乐观锁,处理并发审批;approval_task里的assignee_ids和assignee_names在创建任务时就固化下来,写清是谁。即使后来组织架构调整、人员离职,历史单据依然能看到当时审批人是谁,这是后面避坑章里最值钱的一条经验。
form_schema存放表单定义,flow_config存放节点流程定义,两者都是 JSON。approval_instance.status建议统一定义为 0 审批中、1 通过、2 驳回、3 撤回,避免不同人写不同数值。
2.3 用 JSON Schema 描述表单,别为每个审批类型开发一个页面
表单的动态化是“多场景复用”的关键一步。用一套 JSON 结构描述请假、报销、采购、合同的表单字段,前端渲染引擎就可以复用同一套页面。
{ "fields": [ { "key": "leave_type", "label": "请假类别", "type": "select", "options": ["事假", "病假", "年假"], "required": true }, { "key": "start_time", "label": "开始时间", "type": "datetime", "required": true }, { "key": "end_time", "label": "结束时间", "type": "datetime", "required": true }, { "key": "total_hours", "label": "请假时长(小时)", "type": "number", "required": true }, { "key": "reason", "label": "事由说明", "type": "textarea", "maxLength": 500 }, { "key": "attachment", "label": "附件", "type": "file", "maxSize": 10, "unit": "MB" } ] }字段类型我一般只保留六种:input、number、textarea、select、datetime、file,足够覆盖办公审批的绝大多数场景。select 的options可以是字符串数组,也可以扩展成{label, value}结构。maxSize的单位放在配置里而不是写死在代码中,因为不同场景对附件大小的容忍度不一样,报销凭证可能只要 2MB 以内,合同扫描件却需要 20MB。
与表单 JSON 配套的一定是服务端校验。前端校验只是体验,服务端要重新走一遍:必填检查、枚举值合法性、数字范围、附件大小。否则有人直接调接口塞一个total_hours: -8进来,后续条件分支计算就会出问题。
2.4 创建实例接口:服务端校验与事务边界
创建审批实例的逻辑是整套系统的第一个要塞。用 Flask 风格的接口做演示:
@app.post("/api/v1/approval/instances") def create_instance(payload: dict): current_user = get_current_user(request) # 从登录态取用户,绝不信任前端传的 userId template_code = payload["templateCode"] form_data = payload["formData"] template = get_template(template_code) if template is None or template["status"] != 1: raise BizError("审批模板不存在或已停用") validate_form(template.form_schema, form_data) # 服务端必填、类型、大小校验 with db.transaction(): instance_id = insert_instance( template_code=template_code, title=build_title(template, form_data), applicant_id=current_user.id, form_data=form_data, current_node=template.flow_config["start"]["next"] ) create_task_for_node(instance_id, template.flow_config["start"]["next"], template.flow_config) notify_assignees(instance_id, template.flow_config["start"]["next"]) return {"instanceId": instance_id}get_current_user必须从服务端 session 或 token 里解析用户身份,前端传什么userId都不可信,接口层要做一层防越权。build_title的作用是用模板配置拼出单据标题,比如张三的请假申请 2025-06-01,这样审批列表里一眼就能看出是什么单子,不用展开详情。
notify_assignees在事务提交后再调,避免消息发出去了但数据库回滚导致“收到了通知却查不到单子”的尴尬情况。事务边界只包数据库写操作,外部调用全部放在事务外,这个顺序是血泪经验。
3. 审批人与节点配置:把“找谁签”变成规则计算,覆盖五种指派方式
3.1 审批人规则配置:自选、指定人、部门主管、角色、发起人本人
审批节点里最核心的是assignee配置。常见做法是支持五种规则:applicant_self发起人本人、dept_manager部门主管、role指定角色、user指定成员、applicant_designated发起人自选。请假和外出多用部门主管,报销加一个财务角色节点,采购可能还要指定采购部门负责人。
{ "key": "direct_manager", "type": "approval", "name": "直属主管审批", "assignee": { "rule": "dept_manager", "managerLevel": 1 }, "mode": "sequence", "next": "finance" }managerLevel: 1表示直属主管,2 表示再上一级。这个参数不要写死在代码里,因为不同公司的汇报层级不一样,有的部门主管就是经理,有的还要先过组长再过经理。规则解析器只负责“按层级往上找”,层级由模板配置决定。
部门主管的判定依赖组织架构里的dept.manager_id,这是最直接的一种设计。如果公司有“代理主管”或“临时负责人”这类需求,可以在 dept 表加一个deputy_manager_id字段,解析规则时按优先级取。
3.2 主管是怎么算出来的:组织架构表与汇报关系
组织架构表谁都会建,但有一个坑很隐蔽:审批发生时必须“按当时的组织架构”找人,而不是按现在的。员工调岗后,历史审批单不能跟着变审批人,否则旧单子审批权限会漂移。
我一般会要求组织架构表增加effective_date生效时间,用户表增加dept_id当前部门的同时,在实例表里固化applicant_id和审批人快照。解析主管的伪代码如下:
def resolve_dept_manager(user, level=1): current_dept = get_user_dept(user["id"]) manager = get_dept_manager(current_dept) for _ in range(level - 1): if manager is None: return None manager = get_dept_manager(manager["dept_id"]) return manager这里每一步都要判空。实际交付时经常遇到:某个小部门负责人离职后没维护manager_id,流程走到这个节点直接报错或者把单子卡死。我通常会在解析不到主管时走兜底规则:往上找上级部门的主管,再找不到就转给租户管理员,并在任务记录里打一个标记,方便排查。
3.3 条件分支:报销金额、请假时长决定下一节点
有了规则解析还不够,审批流需要能在节点之间做分支。最常见的是按金额或时长分线,比如报销金额 1000 元以内部门主管直接批,超过 1000 元要加签总经理。条件分支节点的配置长这样:
{ "key": "amount_branch", "type": "condition", "name": "金额分支", "nextByCondition": [ { "when": [{ "field": "amount", "op": "lte", "value": 1000 }], "next": "dept_manager" }, { "when": [{ "field": "amount", "op": "gt", "value": 1000 }], "next": "general_manager" } ], "otherwise": "dept_manager" }条件表达式我用极简的三元组:字段、操作符、值。操作符只需要eq、ne、gt、gte、lt、lte、contains七种,足够覆盖权限、金额、时长、类别的判断。多个条件同时满足用and组合,极少需要or,真遇到了就拆成两个条件节点。
这里要提醒一个细节:条件里引用的字段名是表单 JSON 里的key,不是中文标签,所以key的命名必须稳定。比如报销金额叫amount,不要叫money_total,因为一旦表单发布后有历史数据,改 key 就得写数据迁移脚本。
3.4 会签、或签、逐级审批:三种模式的选择边界
审批节点除了“谁签”,还要定“怎么签”。常见的是三种模式:sequence逐级审批、countersign会签、or或签。逐级审批是 A 签完 B 才能签,会签是所有人都要签且不分先后,或签是其中一个人签了就行。
选型上没有银弹,只有匹配场景。报销、请假这类高频低风险的用逐级审批,合同、采购这类需要多人把关的用会签,而“部门主管或分管领导任一审批即可”这种场景用或签。 配置差别只在一个字段:
{ "key": "countersign_node", "type": "approval", "mode": "countersign", "assignee": { "rule": "role", "roleCode": "finance" } }会签模式下实现要格外小心:同名节点会生成多条任务记录,每条对应一个审批人。只有全部任务处理完才能流转到下一节点;任意一个驳回,整条流程回到发起人。这里最容易出现的 bug 是“部分人签了、部分人没签,流程却提前入下一节点”,原因是实现时把每个任务独立去推进了流程。正确做法是:每个任务处理完先更新任务状态,再统计该节点下所有任务是否全部结束,全部结束才调advance_workflow。
4. 小程序端落地:动态表单、状态控制与消息通知三件事
4.1 小程序页面骨架:工作台、待办、已办、我的申请
小程序端不用追求页面多华丽,四个页面就能跑通全部核心场景:工作台、待办、已办和我的申请。工作台展示可发起的审批类型入口;待办是当前用户要处理的单子;已办是自己处理过的;我的申请是过去发起过的所有单据及当前状态。 发起审批的入口从模板表读,而不是写死在前端,这样才能做到“新增审批类型不改小程序发版”。
页面之间传参不要只传instanceId,还要带上单子的status,详情页根据状态决定展示哪些操作按钮。比如一张已经结束的单子,再进入详情页就不应该渲染“通过/驳回”按钮,否则用户点了才会报错,体验很糟。
统一请求封装是前端的第一步,所有接口都走同一个通道:
const request = ({ url, method = 'POST', data = {} }) => { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${url}`, method, data, header: { Authorization: `Bearer ${getToken()}` }, success: (res) => { if (res.data.code === 0) resolve(res.data.data) else reject(new Error(res.data.message)) }, fail: reject }) }) }登录态通过 token 放在 header 里,小程序端不要存明文用户信息。token 过期时统一拦截跳登录页,不然每次接口报错都很莫名其妙。
4.2 动态表单渲染与前端校验
动态表单渲染说白了就是把服务端返回的formSchema.fields循环渲染出来。WXML 用wx:if按type分发到不同控件:
<block wx:for="{{formFields}}" wx:key="key"> <view class="form-item"> <text class="label">{{item.label}}<text wx:if="{{item.required}}" class="required">*</text></text> <input wx:if="{{item.type === 'input'}}" value="{{item.value}}">const canApprove = task.assigneeIds.includes(currentUser.id) && task.status === 0 && instance.status === 0; const canRevoke = instance.applicantId === currentUser.id && instance.status === 0 && taskList.every(t => t.status === 0) && currentNode.type !== 'condition';这里的canApprove必须同时满足三个条件:我是任务处理人、任务还在待办、实例还在审批中。缺少任何一个,说明这个按钮不该显示。canRevoke的意思是发起人只在没有任何人处理过的情况下才能撤回,一旦有人签过字就不再允许单方面撤回。实际前端的判断只是一层交互保护,最终还是要由后端接口做同样的校验,两端逻辑要保持一致,避免“按钮显示了但接口拒绝”的尴尬。
4.4 订阅消息与超时催办:通知参数怎么设
小程序订阅消息的特点是“用户授权一次,只能推送一次”,这是刚入坑时最容易踩的预期差。常见做法是在发起审批成功后引导发起人订阅消息,这样审批人处理时能给发起人发一条结果通知;审批人那边则要在待办列表页进入时做订阅引导,这样下一单处理结果才能触达。
wx.requestSubscribeMessage({ tmplIds: ['template_id_xxx'], success(res) { if (res['template_id_xxx'] === 'accept') { // 用户同意订阅,后端可推送一次结果通知 } } })后端调用订阅消息发送接口时,要传touser(用户 openid)、template_id、page、data四个核心参数。page最好指到详情页并带上 instanceId,用户点开直达单子,不要只进首页。
超时催办用定时任务扫描待办。参数上我会设置两个:超时阈值和最大提醒次数。阈值按业务分,请假审批 4 小时就算超时,合同审批给 24 小时;提醒次数默认 3 次,避免一天催五次被人投诉。定时任务里要记录last_remind_at或remind_count,任务断点重跑也不会重复打扰。
5. 避坑指南:审批小程序最容易翻车的五个细节
5.1 越权查看:光校验登录绕过数据权限,全部单子泄露
现象:一个普通员工能通过改接口参数看到别人的请假单、报销金额,甚至包括总经理的审批记录。原因:接口只校验了“是否登录”,没有校验“是否有权限看这张单”。我在实际交付中见过只按 instanceId 查询、完全不判断当前用户是不是申请人或审批人的实现。解决:所有查询类的接口在返回前必须做数据范围校验。规则是申请人本人、当前待办或已办的审批人、抄送人、以及具备管理权限的角色才能看这张单。后端统一封装一个checkInstanceVisible(instance, currentUser),所有查询入口都过一遍,没有例外。
5.2 并发双击:重复提交把实例状态改坏
现象:两个审批人同时点了“通过”,结果实例被推进两次,或者同一个任务被处理了两次,流程出现两条后续分支。原因:审批任务处理没有做并发控制,两个请求同时读到任务状态都是待办,都继续执行了。解决:更新任务状态时带上乐观锁,用status = 0作为更新条件,更新成功影响行数为 0 就说明已被处理。
UPDATE approval_task SET status = 1, action_at = NOW(), comment = %s WHERE id = %s AND status = 0发布到生产环境前,一定要用两个账号或两个浏览器同时对同一任务点“通过”做一次联调,很多事故只有并发触发时才暴露出来。
5.3 日期少一天与金额差一分:数据库精度玄学
现象:用户在前端选了 6 月 1 日,审批详情里却显示 5 月 31 日;报销金额明明填的是 1234.56,数据库里却存成了 1234.559999。原因:日期字段在前后端传输时发生了时区转义,MySQL 的DATETIME不带时区,而 JDBC 连接配置的serverTimezone可能与服务器不一致;金额用了FLOAT或DOUBLE,浮点存储天然有误差。解决:日期字段统一定义为字符串,格式YYYY-MM-DD或YYYY-MM-DD HH:mm:ss直接入库。金额用整数分存储,前端输入元、后端转换成分、展示再转回元,或者在数据库里用DECIMAL(14,2)并在所有计算里统一用定点数。关键点是“展示层格式化”,不要靠数据库隐式转换。
5.4 附件垃圾:临时文件未转正与存储膨胀
现象:用户发起审批时上传了 20MB 附件,最后把申请撤回了或者根本没提交成功,OSS 或服务器存储里却残留了大量文件,一个月多占几个 GB。原因:上传接口没有和审批实例创建接口打通,文件上传成功就认为是永久文件,没有“临时上传 → 审批通过后转正 → 被丢弃后清理”的机制。解决:上传接口统一写入临时目录或临时对象存储路径,只有审批实例创建成功后才把附件标记为正式文件,同时提供一个定时清理任务,把超过 24 小时未被引用的临时文件删掉。给文件命名设计统一规则时,带上instanceId或applicantId前缀,排查问题时能直接定位到是谁传的。
5.5 组织调整后旧单子“查无此人”和历史审批链断掉
现象:某个员工三个月前提交的报销单,现在想看历史审批记录,结果审批人姓名显示为空,或者因为部门被合并导致主管解析失败,老单子的流程时间线断了一截。原因:实例和任务只存了用户 ID,没有存姓名和部门快照,用户表和部门表的数据一变,历史数据跟着失效。解决:在approval_task表固化assignee_names,在approval_instance表固化申请人姓名、部门名称和当时的表单数据。职位调整、部门合并都是常态,唯一可靠的做法是“审批发生时把要展示的信息都拍一张快照存下来”。
6. 进阶:把审批底座做成可复用能力,附全套验收清单
一套审批系统真正能用起来,不是写完审批流就算结束,而是要能响应“再帮我加一个审批类型”这句话。我一般会在模板管理后台提供配置界面,至少包括表单字段配置、节点顺序配置、审批人规则选择、条件分支设置四块内容。支持模板导入导出,这样公司有多个部门、甚至多个单位时,可以直接复制模板再改字段,不需要从零配置。进一步可以开放 OpenAPI,让其他系统按约定格式调用发起接口,例如财务系统里直接发起一笔报销审批。做到这一步,审批就从“一个功能”变成了“一个可复用的底座”。
上线前的验收清单我反复用过很多轮,每一轮都能筛出问题,这里直接整理给你:
| 验收项目 | 关键检查点 |
|---|---|
| 模板配置 | 请假、报销、采购、合同各建一套,改模板字段后新建单据立即生效 |
| 节点流转 | 条件分支按金额/时长正确路由;驳回后回到发起人可修改再提交 |
| 审批人解析 | 主管缺失时走兜底规则;自选审批人只能选组织架构内成员 |
| 数据权限 | 非相关人员看不到他人单据;审批人只能看到与自己相关的流程 |
| 消息通知 | 订阅消息在合理时间内送达;超时催办按阈值触发且不重复打扰 |
| 并发处理 | 两个终端同时操作同一任务,只有一个成功,另一个提示已处理 |
| 历史归档 | 组织架构调整后,历史单据的审批人姓名和部门仍完整显示 |
这套底线全部跑通之后再放量推广。我自己的习惯是让后勤同事拿真实场景各发一单:请假、报销、外出和合同各跑一遍全链路,从发起、审批、驳回、重提、撤回全走一遍,确认没有一处卡壳才开始培训其他人。审批系统最怕的是上线第一天就在众目睽睽之下出 bug,那之后再去解释“这是并发问题”已经没有意义。代码结构可以逐步优化,但流程正确性必须在上线前钉死。边界清楚了、参数可配了、历史不漂移了,这个审批小程序才真正算交付完成。希望帮到你。
本文还有配套的精品资源,点击获取