后端扩展点实战
本文介绍 BPMAX 插件后端的基础结构和开发方式,目标是让你能编写插件接口、服务逻辑、安装脚本和简单数据模型。
学习目标
- 理解插件后端目录的职责分层
- 学会新增控制器、服务和模型
- 学会使用安装脚本和卸载脚本
插件后端目录结构
一个典型插件后端通常包含以下目录:
server/src/
├── controller/
│ └── plugin_example.js
├── service/
│ └── plugin_example.js
├── model/
├── bootstrap/
│ └── hooks.js
├── install/
│ ├── plugin_example_install.js
│ └── plugin_example_uninstall.js
└── config/
└── crontab_job.js接口层
用于暴露插件接口,对应平台 API 路由入口。
控制器建议只负责:
- 接收参数
- 基础校验
- 调用服务
- 返回响应
service
用于承载真正的业务逻辑,例如:
- 调用第三方接口
- 组装数据
- 执行同步或异步流程
- 处理缓存和重试
model
适合处理:
- 插件自有表
- 插件队列数据
- 插件配置持久化
bootstrap/hooks
用于注册 Hook,把插件逻辑挂到平台事件上。
install
用于安装和卸载时执行初始化逻辑。
config/crontab_job
用于定义插件定时任务。
编写一个最小接口
控制器入口
最简单的后端接口通常从接口层开始。例如:
import BaseRest from '../rest.js';
@think.RestController()
export default class extends BaseRest {
async pingAction() {
return this.success({
plugin: 'plugin_example',
message: 'pong',
});
}
}访问路径可以先约定为:
/api/plugin_example/ping这类控制器可以先作为最小后端入口,后续再补具体 action。
请求与响应约定
插件控制器通常沿用平台已有控制器风格:
- 继承
BaseRest - 使用
this.success(...) - 使用
this.fail(...)
这样能和平台其他接口保持一致。
如果需要参数校验,可以先从最简单的写法开始:
async saveConfigAction() {
const { enabled, platform_id } = this.post();
if (!platform_id) {
return this.fail('platform_id is required');
}
const result = await this.service('plugin_example').saveConfig({
enabled,
platform_id,
});
return this.success(result);
}路由命名约定
建议接口名与插件英文名保持一致,例如:
/api/plugin_example/.../api/plugin_task_integration/...
建议保持:
- 插件前缀明确
- 含义稳定
- 尽量避免缩写混乱
编写服务层逻辑
服务层职责
服务层应该承接核心业务,而不是把逻辑堆在控制器里。
例如任务集成类插件的服务层可以负责:
- 获取第三方访问令牌
- 创建外部任务
- 更新或完成外部任务
一个最小服务示例如下:
export default class PluginExampleService extends think.Service {
async saveConfig(data) {
const payload = {
enabled: Boolean(data.enabled),
platform_id: data.platform_id,
updated_at: Date.now(),
};
return payload;
}
}如果要调用第三方接口,可以进一步拆成:
export default class PluginExampleService extends think.Service {
async getAccessToken(appId, secret) {
const res = await this.http.post('/oauth/token', {
app_id: appId,
secret,
});
return res.data.access_token;
}
async createTask(platformConfig, payload) {
const token = await this.getAccessToken(
platformConfig.app_id,
platformConfig.secret
);
return this.http.post('/tasks', payload, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
}何时调用平台服务
插件后端可以调用平台已有服务,例如:
- Redis
- OSS
- 消息
- 平台配置
调用前建议先确认平台中是否已有成熟能力,能复用时尽量复用。
日志与异常处理
后端插件建议至少做到:
- 关键分支打日志
- 第三方调用错误带上下文
- 对外响应尽量稳定
特别是集成类插件,失败时一定要能判断是:
- 参数问题
- 配置问题
- 第三方网络问题
- 权限或鉴权问题
何时需要模型层
插件独立数据表
如果插件需要存储自己的业务记录、缓存队列或绑定关系,就需要模型层。
例如可以为任务绑定关系建立一个简单模型:
export default class PluginTaskMapModel extends think.Model {
get tableName() {
return 'plugin_task_map';
}
}插件自有表怎么设计
先判断是否真的需要自有表
不是所有插件都要建表。通常只有下面几类场景才建议维护插件自有表:
- 需要保存插件自己的业务状态
- 需要记录平台对象与第三方对象的绑定关系
- 需要实现失败重试、补偿、队列或审计
- 需要高频读取且不适合每次都现场计算
如果只是一次性调用第三方接口,且没有后续状态跟踪,很多时候服务层加配置就够了。
常见表类型
插件自有表通常可以分成四类:
- 绑定关系表
- 任务状态表
- 日志或审计表
- 队列或重试表
例如任务集成类插件,常见会拆成:
plugin_task_map
plugin_task_retry
plugin_task_audit_log绑定关系表怎么设计
绑定关系表用于记录“平台对象”和“第三方对象”的对应关系,字段一般要包含:
- 平台主键
- 第三方主键
- 当前状态
- 创建时间
- 更新时间
一个表结构示例如下:
CREATE TABLE plugin_task_map (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
project_guid VARCHAR(64) NOT NULL,
step_guid VARCHAR(64) NOT NULL,
external_task_id VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uk_project_step (project_guid, step_guid)
);这类表最关键的是唯一键设计。要先回答:
- 一个流程实例是否只允许绑定一个外部对象
- 一个流程环节是否允许重复创建任务
只有先明确业务唯一性,唯一索引才能定得准。
重试表怎么设计
如果插件存在失败补偿或异步重试,建议单独拆一张重试表,不要把所有字段都堆在主业务表里。
例如:
CREATE TABLE plugin_task_retry (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_map_id BIGINT NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at BIGINT NOT NULL,
last_error_message TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);这样做有两个好处:
- 主表保持稳定,不被临时状态污染
- 重试策略可以独立演进
审计日志表怎么设计
只要插件对接外部系统,通常都值得留一张最小审计表:
CREATE TABLE plugin_task_audit_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
project_guid VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
request_id VARCHAR(128),
result VARCHAR(32) NOT NULL,
message TEXT,
created_at BIGINT NOT NULL
);这类表不要追求保存完整请求体,重点是留下可定位的摘要字段:
- 对象是谁
- 做了什么动作
- 成功还是失败
- 哪个请求触发的
模型层示例
对应到插件模型,可以拆成下面这样:
export class PluginTaskMapModel extends think.Model {
get tableName() {
return 'plugin_task_map';
}
}
export class PluginTaskRetryModel extends think.Model {
get tableName() {
return 'plugin_task_retry';
}
}服务层调用时建议保持边界清晰:
async bindExternalTask(data) {
return this.model('plugin_task_map').add({
project_guid: data.project_guid,
step_guid: data.step_guid,
external_task_id: data.external_task_id,
status: 'pending',
created_at: Date.now(),
updated_at: Date.now(),
});
}字段设计原则
设计插件自有表时,建议优先遵循:
- 字段名直接表达业务含义,不用过度缩写。
- 状态字段统一枚举值,不要不同表各写一套含义相近的状态。
- 所有业务关键表都带
created_at和updated_at。 - 外部对象 id 使用足够宽的字符串字段,避免后续第三方 id 长度不够。
- 尽量显式设计唯一键,而不是依赖代码层“应该不会重复”。
什么时候拆表,什么时候加字段
判断标准可以很简单:
- 如果字段描述的是“主体的稳定属性”,优先加在主表
- 如果字段描述的是“重试、日志、事件、历史”,优先拆表
- 如果某类数据增长快、查询模式不同,也优先拆表
例如:
external_task_id适合放主表retry_count可以放重试表last_error_message更适合放重试表或日志表- 回调原始报文更适合单独日志表
常见建模错误
最常见的问题有:
- 一个表既存主业务状态,又存日志、重试、回调、缓存
- 没有唯一键,导致重复创建外部对象后无法修复
- 直接把第三方原始响应整包塞进主表
- 没有时间字段,后续排障只能靠日志猜测
插件配置缓存
如果配置读取频繁,也可以由模型层或服务层做缓存封装。
与平台模型协作
很多插件并不维护完整的独立数据表,而是通过服务层调用平台模型完成业务联动。
常见平台 Model 能力
这一节总结的是实际插件中最常见、复用率最高的平台模型能力。
对于外部插件开发,优先理解这些能力,比一开始就自建整套业务表更重要。
project 模型
最常见用途:
- 查询流程实例
- 创建子流程或关联流程
- 更新项目扩展字段
- 按业务条件筛选项目
最常见写法:
const projectModel = this.model('project');
const project = await projectModel
.field(['id', 'name', 'ext_config'])
.where({
id: projectId,
is_del: 0,
})
.find();在现有插件里,project 最常被复用的能力之一是 createProject:
const result = await this.model('project').createProject({
flow_type: 'default',
key: 'flow_student_signup',
user_id: studentId,
project_id: parentProjectId,
step_id: 0,
workspace: 'default',
projectForm: {
key_to_value: {
studentId,
studentName,
signUpStatus: 0,
},
},
});适用场景:
- 根据主流程自动创建子流程
- 批量创建待处理业务实例
- 从插件动作触发新流程
form 模型
最常见用途:
- 回写表单字段
- 批量补充插件计算结果
- 把第三方返回值落回流程表单
实际插件中非常常见的方法是 patchFormField:
await this.model('form').patchFormField({
project_id,
form: {
key_to_value: {
externalTaskId: externalTaskId,
syncStatus: 'success',
syncTime: Date.now(),
},
},
});适用场景:
- 第三方单号、回执号回填
- 自动计算字段回写
- 执行动作后更新展示字段
step 模型
最常见用途:
- 查询当前待办环节
- 执行流程流转
- 基于动作推进到目标环节
现有插件里最常用的是 solveStepByAction:
await this.model('step').solveStepByAction(
'approve',
stepId,
{
project_id,
target_step: nextStepId,
},
this.ctx.userInfo
);适用场景:
- 插件自动审批或自动驳回
- 定时任务到点自动推进流程
- 第三方回调后驱动流程继续执行
写这类逻辑时要特别注意:
stepId必须是当前有效环节target_step必须与流程定义一致- 执行人上下文要明确,不能随意传空对象
message 模型
最常见用途:
- 发送站内消息
- 构造带项目链接的通知
- 在插件动作完成后通知相关人
常见模式如下:
const messageModel = this.model('message');
const messageData = messageModel.buildMessageDataForProjectLink(
'任务处理完成',
'您有一个新的处理结果,请点击查看详情。',
project_id,
step_id
);
await messageModel.sendMessage(
fromUserId,
[toUserId],
messageData,
[workspace],
{
project_id,
step_id,
}
);适用场景:
- 给流程负责人发处理提醒
- 给发起人发完成通知
- 给下一处理人发待办消息
其他常见基础模型
已有插件里也经常直接复用这些模型:
user:查询人员基础信息group:查询组织、门店、部门等组织结构数据role、user_role:按角色反查用户pending_task:处理插件生成的待办记录form_field:按字段维度做定向查询
推荐原则:
- 能通过平台已有模型完成的业务联动,优先不要自己再维护一份镜像表
- 只有当你需要插件专属状态、审计、重试机制时,再设计插件自有表
常见平台 Service 与底层能力
这一部分区分两类:
- 平台核心服务:通常是插件联动流程时最常见的能力
- 公共扩展服务:有些环境会提供,使用前应确认是否已安装
核心能力:流程同步与索引刷新
实际插件里频繁出现的是 bpmax_server 相关能力,例如:
activeSyncProjectDataforceSyncProject
常见写法:
await this.service('bpmax_server').activeSyncProjectData(project_id);或:
await this.service('bpmax_server').forceSyncProject(project_id);适用场景:
patchFormField之后需要刷新项目展示数据- 自动流转后需要立刻让列表、详情、搜索结果同步
- 批量修改流程数据后主动触发同步
经验建议:
- 单条更新优先用
activeSyncProjectData - 强制重建或复杂补偿场景再考虑
forceSyncProject - 批量处理时优先合并 id,避免一条一条同步造成压力
公共能力:消息与群通知
在一些已安装消息能力的环境中,常见会复用这些服务:
plugin_common_feishuplugin_feishu_group_serviceplugin_feishu_message_serviceplugin_common_widget
实际插件里出现过的典型能力包括:
getUserOpenIdaddUserToChatGroupsendMsgChatGroupsendMessage
示例:
const openUser = await this.service('plugin_common_feishu').getUserOpenId({
user_id: userId,
});
await this.service('plugin_common_feishu').sendMsgChatGroup({
chat_id,
msg_type: 'text',
content: `流程 ${projectName} 已更新`,
});这类服务适合:
- 发送群通知
- 把用户加入群聊
- 给第三方协作平台推送消息
注意:
- 这类能力往往依赖额外的集成插件或环境配置
- 外部插件在接入前应先确认部署环境中是否已提供对应服务
公共能力:导出文件
部分环境会提供导出服务,例如 streamExportExcel 这一类能力:
const url = await this.service('plugin_export_file_service').streamExportExcel({
fileName: 'certificate-list.xlsx',
columns: [
{ header: '姓名', key: 'userName', width: 20 },
{ header: '状态', key: 'status', width: 16 },
],
getBatchData: async (page, pageSize) => {
return await queryPage(page, pageSize);
},
});适用场景:
- 插件报表导出
- 批量台账导出
- 后台异步生成 Excel
公共能力:敏感字段加解密
在处理手机号、身份证号等字段时,已有插件中常见的做法是复用统一加密服务:
const encryptedPhone = this.service('plugin_common_crypto_service').encryptString(
phone
);或:
const phone = await this.service('plugin_common_crypto_service').decryptPhone(
encryptedPhone
);适用场景:
- 敏感信息脱敏存储
- 向第三方传输前统一加密
- 导出或展示前按规则解密
什么时候可以直接用 SDK
如果插件只是调用一个外部 HTTP API,通常有两种实现路径:
- 直接在插件服务层中封装
axios或 HTTP Client - 先封装一个插件自己的 SDK / Client,再由 service 调用
更推荐第二种,当你满足以下条件时:
- 第三方接口不止 1 个
- 有统一鉴权、重试、签名逻辑
- 后续多个 action / hook / cron 都会复用
示例:
import axios from 'axios';
export class ExternalTaskClient {
constructor(baseURL: string, token: string) {
this.client = axios.create({
baseURL,
headers: {
Authorization: `Bearer ${token}`,
},
timeout: 10000,
});
}
async createTask(payload: Record<string, any>) {
return this.client.post('/tasks', payload);
}
}然后在插件 service 里调用:
export default class PluginTaskService extends think.Service {
async createExternalTask(config, payload) {
const client = new ExternalTaskClient(config.baseUrl, config.token);
const res = await client.createTask(payload);
return res.data;
}
}后端能力选择顺序
可以按下面顺序判断:
- 先看平台现有 model 是否已经能完成数据读写
- 再看平台核心 service 是否已经覆盖同步、消息、导出等通用能力
- 若环境中已安装公共集成服务,可以直接复用
- 若以上都不满足,再为插件自己封装 SDK 或新建数据表
这样做能明显减少:
- 重复造轮子
- 数据副本过多
- 插件升级时的兼容成本
安装与卸载脚本
适用场景
以下场景建议增加安装脚本:
- 初始化配置
- 初始化基础数据
- 创建必要绑定关系
以下场景建议增加卸载脚本:
- 清理绑定关系
- 释放外部注册
- 回收插件初始化的系统资源
命名约定
通常放在 src/install/ 下,并遵循:
{plugin_en_name}_install.js{plugin_en_name}_uninstall.js
平台安装机制会按这个约定查找对应文件。
安装脚本最小示例如下:
export default class extends think.Service {
async run() {
think.logger.info('[plugin_example] install start');
return true;
}
}卸载脚本可以保持同样结构:
export default class extends think.Service {
async run() {
think.logger.info('[plugin_example] uninstall start');
return true;
}
}执行时机
从平台实现看:
- 首次安装时会尝试执行 install 脚本
- 卸载时会尝试执行 uninstall 脚本
因此脚本要尽量做到可重复执行或至少可安全失败。
后端开发常见风险
文件命名冲突导致覆盖平台文件
当前插件安装机制会把文件写入平台运行环境,所以一定要避免插件文件名与平台已有文件重名,否则可能覆盖平台实现。
安装脚本不可重复执行
如果安装脚本假设“只会执行一次”,升级或重复安装时就容易出问题。建议脚本本身做好存在性检查。
插件升级时的兼容性问题
升级时最容易出问题的是:
- 旧配置结构和新代码不兼容
- 新字段没有默认值
- 历史数据没有迁移
