feat: add visual process designer
This commit is contained in:
+8
@@ -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>)
|
||||
|
||||
+82
@@ -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("&","&").replace("<","<").replace(">",">").replace("\"",""")
|
||||
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
|
||||
+29
@@ -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")))) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user