feat: add visual process designer

This commit is contained in:
selfrelease
2026-07-18 22:56:29 +08:00
parent 89c44a61a7
commit 9c366cc872
10 changed files with 210 additions and 4 deletions
@@ -45,6 +45,14 @@ class ProcessConfigurationController(
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
}
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
@PostMapping("/processes/deploy") fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map<String,Any?> {
val a=actor(jwt);val xml=generateProcessTemplate(body)
val deployment=flowable.createDeployment().name("AIOA Designer: ${body.name}").addString("${body.key}.bpmn20.xml",xml).deploy()
val definition=flowable.createProcessDefinitionQuery().deploymentId(deployment.id).singleResult()
?: throw ApiException(HttpStatus.INTERNAL_SERVER_ERROR,"PROCESS_DEPLOYMENT_FAILED","流程部署失败")
audit.recordSuccess(a,"PROCESS_DEFINITION_DEPLOYED","PROCESS_DEFINITION",definition.id,null,mapOf("key" to definition.key,"version" to definition.version,"mode" to body.mode))
return mapOf("id" to definition.id,"key" to definition.key,"version" to definition.version,"deploymentId" to deployment.id)
}
private fun validateSchema(data:Map<String,Any>,ui:Map<String,Any>){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
}
data class FormVersionCommand(val formKey:String,val dataSchema:Map<String,Any>,val uiSchema:Map<String,Any>)
@@ -0,0 +1,82 @@
package com.all8ai.aioa.admin.configuration
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
data class ApprovalStepCommand(val name:String,val assigneeVariable:String)
data class ProcessTemplateCommand(
val key:String,val name:String,val mode:String,val steps:List<ApprovalStepCommand>,
val conditionVariable:String="durationMinutes",val conditionThreshold:Long=1440,
)
private val safeKey=Regex("[A-Za-z][A-Za-z0-9_-]{2,63}")
private val allowedAssignees=setOf("approverId","oaAdministratorId","hrReviewerId")
private val allowedConditions=setOf("durationMinutes")
fun generateProcessTemplate(command:ProcessTemplateCommand):String {
if(!safeKey.matches(command.key)) invalid("流程 Key 必须以字母开头且只能包含字母、数字、下划线或连字符")
if(command.name.isBlank()||command.name.length>100) invalid("流程名称长度无效")
if(command.mode !in setOf("SERIAL","PARALLEL","CONDITIONAL")) invalid("流程模式无效")
if(command.mode=="CONDITIONAL" && command.steps.size!=2 || command.mode!="CONDITIONAL" && command.steps.size !in 1..6) invalid("审批节点数量无效")
if(command.steps.any{it.name.isBlank()||it.name.length>80||it.assigneeVariable !in allowedAssignees}) invalid("审批节点配置无效")
if(command.conditionVariable !in allowedConditions||command.conditionThreshold<0) invalid("条件配置无效")
val body=when(command.mode){
"SERIAL"->serial(command.steps)
"PARALLEL"->parallel(command.steps)
else->conditional(command.steps,command.conditionVariable,command.conditionThreshold)
}
return """<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="https://aioa.all8ai.com/designer">
<process id="${command.key}" name="${xml(command.name)}" isExecutable="true">
$body
</process>
</definitions>"""
}
private fun serial(steps:List<ApprovalStepCommand>):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
steps.forEachIndexed { index,step ->
appendDecisionTask(index,step,if(index==steps.lastIndex) "approvedEnd" else "task${index+1}")
}
appendEnds()
}.trimEnd()
private fun parallel(steps:List<ApprovalStepCommand>):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-split\" sourceRef=\"start\" targetRef=\"parallelSplit\"/>")
appendLine(" <parallelGateway id=\"parallelSplit\" name=\"并行会签\"/>")
steps.forEachIndexed { index,step ->
appendLine(" <sequenceFlow id=\"flow-split-task$index\" sourceRef=\"parallelSplit\" targetRef=\"task$index\"/>")
appendDecisionTask(index,step,"parallelJoin")
}
appendLine(" <parallelGateway id=\"parallelJoin\" name=\"全部通过\"/>")
appendLine(" <sequenceFlow id=\"flow-join-end\" sourceRef=\"parallelJoin\" targetRef=\"approvedEnd\"/>")
appendEnds()
}.trimEnd()
private fun conditional(steps:List<ApprovalStepCommand>,variable:String,threshold:Long):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
appendDecisionTask(0,steps[0],"routeDecision")
appendLine(" <exclusiveGateway id=\"routeDecision\" name=\"条件路由\"/>")
appendLine(" <sequenceFlow id=\"flow-condition-short\" sourceRef=\"routeDecision\" targetRef=\"approvedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable <= $threshold}]]></conditionExpression></sequenceFlow>")
appendLine(" <sequenceFlow id=\"flow-condition-long\" sourceRef=\"routeDecision\" targetRef=\"task1\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable > $threshold}]]></conditionExpression></sequenceFlow>")
appendDecisionTask(1,steps[1],"approvedEnd")
appendEnds()
}.trimEnd()
private fun StringBuilder.appendDecisionTask(index:Int,step:ApprovalStepCommand,approvedTarget:String) {
appendLine(" <userTask id=\"task$index\" name=\"${xml(step.name)}\" flowable:assignee=\"\${${step.assigneeVariable}}\"/>")
appendLine(" <sequenceFlow id=\"flow-task$index-decision\" sourceRef=\"task$index\" targetRef=\"decision$index\"/>")
appendLine(" <exclusiveGateway id=\"decision$index\" name=\"审批结果\"/>")
appendLine(" <sequenceFlow id=\"flow-task$index-approved\" sourceRef=\"decision$index\" targetRef=\"$approvedTarget\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == true}]]></conditionExpression></sequenceFlow>")
appendLine(" <sequenceFlow id=\"flow-task$index-rejected\" sourceRef=\"decision$index\" targetRef=\"rejectedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == false}]]></conditionExpression></sequenceFlow>")
}
private fun StringBuilder.appendEnds(){
appendLine(" <endEvent id=\"approvedEnd\" name=\"已批准\"/>")
appendLine(" <endEvent id=\"rejectedEnd\" name=\"已驳回\"><terminateEventDefinition/></endEvent>")
}
private fun xml(value:String)=value.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace("\"","&quot;")
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
@@ -0,0 +1,29 @@
package com.all8ai.aioa.admin.configuration
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFails
class ProcessTemplateGeneratorTest {
private val manager=ApprovalStepCommand("主管审批","approverId")
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
@Test fun `generates serial approval with rejection path`() {
val xml=generateProcessTemplate(ProcessTemplateCommand("serialDemo","串行审批","SERIAL",listOf(manager,oa)))
assertContains(xml,"sourceRef=\"decision0\" targetRef=\"task1\"")
assertContains(xml,"targetRef=\"rejectedEnd\"")
}
@Test fun `generates parallel gateways and condition expressions`() {
val parallel=generateProcessTemplate(ProcessTemplateCommand("parallelDemo","并行审批","PARALLEL",listOf(manager,oa)))
assertContains(parallel,"parallelGateway id=\"parallelSplit\"")
assertContains(parallel,"parallelGateway id=\"parallelJoin\"")
val conditional=generateProcessTemplate(ProcessTemplateCommand("conditionalDemo","条件审批","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
assertContains(conditional,"durationMinutes <= 960")
}
@Test fun `rejects unsafe identifiers and assignee expressions`() {
assertFails { generateProcessTemplate(ProcessTemplateCommand("bad key","测试","SERIAL",listOf(manager))) }
assertFails { generateProcessTemplate(ProcessTemplateCommand("safeKey","测试","SERIAL",listOf(ApprovalStepCommand("审批","evilExpression")))) }
}
}