feat: add versioned form and process routing

This commit is contained in:
selfrelease
2026-07-18 21:26:44 +08:00
parent 24101398d8
commit 15abd8a32f
14 changed files with 319 additions and 63 deletions
@@ -0,0 +1,48 @@
package com.all8ai.aioa.admin.configuration
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.flowable.engine.RepositoryService
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController @RequestMapping("/api/v1/admin/process-configuration")
class ProcessConfigurationController(
private val users:CurrentUserService,private val dsl:DSLContext,private val mapper:ObjectMapper,
private val flowable:RepositoryService,private val audit:AuditService,
) {
private fun actor(jwt:Jwt)=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT) }
@GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} }
@GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} }
@PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map<String,Any?> {
val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1)
dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema))
audit.recordSuccess(a,"FORM_VERSION_CREATED","FORM_DEFINITION","${body.formKey}:$version",null,mapOf("version" to version));return mapOf("formKey" to body.formKey,"version" to version,"status" to "DRAFT")
}
@PostMapping("/forms/{key}/{version}/publish") @Transactional fun publish(@AuthenticationPrincipal jwt:Jwt,@PathVariable key:String,@PathVariable version:Int) {
val a=actor(jwt);dsl.execute("UPDATE form.definition SET status='RETIRED' WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",a.tenantId,key)
if(dsl.execute("UPDATE form.definition SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND form_key=? AND version=? AND status='DRAFT'",a.tenantId,key,version)!=1) throw ApiException(HttpStatus.CONFLICT,"FORM_VERSION_NOT_DRAFT","表单版本不存在或不可发布")
audit.recordSuccess(a,"FORM_VERSION_PUBLISHED","FORM_DEFINITION","$key:$version",null,mapOf("version" to version))
}
@PostMapping("/bindings") @Transactional fun createBinding(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:BindingCommand):Map<String,Any?> {
val a=actor(jwt);if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
val id=UuidV7.generate();dsl.execute("INSERT INTO workflow.process_binding(id,tenant_id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE')",id,a.tenantId,body.businessType,body.formKey,body.formVersion,body.processDefinitionKey,body.leaveType,body.minDurationMinutes,body.maxDurationMinutes,body.priority)
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)) }
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>)
data class BindingCommand(val businessType:String,val formKey:String,val formVersion:Int,val processDefinitionKey:String,val leaveType:String?=null,val minDurationMinutes:Long?=null,val maxDurationMinutes:Long?=null,val priority:Int=100)
data class BindingStatusCommand(val status:String)
@@ -25,6 +25,8 @@ import java.time.Duration
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
@Service
class LeaveRequestService(
@@ -33,6 +35,7 @@ class LeaveRequestService(
private val routingRepository: ApprovalRoutingRepository,
private val workflowGateway: LeaveWorkflowGateway,
private val notificationService: NotificationService? = null,
private val processBindingRouter: ProcessBindingRouter? = null,
) {
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
@@ -86,6 +89,13 @@ class LeaveRequestService(
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val existing = getOwn(actor, id)
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
val processDefinitionKey = processBindingRouter?.select(
actor.tenantId, "LEAVE_REQUEST", existing.type.name, durationMinutes,
)?.processDefinitionKey ?: if (processBindingRouter == null) {
FlowableLeaveWorkflowGateway.PROCESS_DEFINITION_KEY
} else {
throw ApiException(HttpStatus.CONFLICT, "PROCESS_BINDING_NOT_FOUND", "未找到适用的已启用审批流程")
}
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
if (approverId == actor.id) {
@@ -121,7 +131,8 @@ class LeaveRequestService(
)
if (outcome.replayed) return outcome.leaveRequest
val process = workflowGateway.startLeaveApproval(
val process = workflowGateway.startLeaveApprovalWithDefinition(
processDefinitionKey,
actor.tenantId,
outcome.leaveRequest.id,
actor.id,
@@ -1,76 +1,64 @@
package com.all8ai.aioa.forms.api
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1/form-definitions")
class FormDefinitionController {
class FormDefinitionController(
private val currentUsers: CurrentUserService? = null,
private val dsl: DSLContext? = null,
private val objectMapper: ObjectMapper? = null,
) {
@GetMapping("/{formKey}")
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
fun getPublished(@AuthenticationPrincipal jwt: Jwt, @PathVariable formKey: String): FormDefinitionResponse {
val actor = currentUsers!!.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
return find(actor.tenantId, formKey) ?: notFound()
}
// 保留纯单元测试与离线内置定义入口;生产 HTTP 始终读取已发布数据库版本。
fun getDefinition(formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> notFound()
}
private fun find(tenantId: UUID, key: String): FormDefinitionResponse? = dsl!!.fetchOne(
"SELECT * FROM form.definition WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",
tenantId, key,
)?.let {
@Suppress("UNCHECKED_CAST")
FormDefinitionResponse(
it.get("form_key", String::class.java)!!,
it.get("version", Int::class.java)!!,
objectMapper!!.readValue(it.get("data_schema")!!.toString(), Map::class.java) as Map<String, Any>,
objectMapper.readValue(it.get("ui_schema")!!.toString(), Map::class.java) as Map<String, Any>,
)
}
private fun notFound(): Nothing = throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
companion object {
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
val leaveRequestDefinition = FormDefinitionResponse(
key = LEAVE_REQUEST_FORM_KEY,
version = 1,
dataSchema = mapOf(
"\$id" to "leave-request-v1",
"title" to "请假申请",
"type" to "object",
"required" to listOf("type", "startsAt", "endsAt", "reason"),
"properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
"endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000),
),
),
uiSchema = mapOf(
"description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。",
"sections" to listOf(
mapOf(
"title" to "请假信息",
"controls" to listOf(
mapOf(
"field" to "type",
"label" to "请假类型",
"control" to "select",
"optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假"),
),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"),
mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"),
),
),
mapOf(
"title" to "补充说明",
"controls" to listOf(
mapOf(
"field" to "reason",
"label" to "请假原因",
"control" to "textArea",
"placeholder" to "请简要说明请假原因",
"helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。",
),
),
),
),
),
LEAVE_REQUEST_FORM_KEY, 1,
mapOf("\$id" to "leave-request-v1", "title" to "请假申请", "type" to "object", "required" to listOf("type", "startsAt", "endsAt", "reason"), "properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"), "endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000))),
mapOf("description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。", "sections" to listOf(
mapOf("title" to "请假信息", "controls" to listOf(
mapOf("field" to "type", "label" to "请假类型", "control" to "select", "optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假")),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"), mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"))),
mapOf("title" to "补充说明", "controls" to listOf(mapOf("field" to "reason", "label" to "请假原因", "control" to "textArea", "placeholder" to "请简要说明请假原因", "helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。"))))),
)
}
}
data class FormDefinitionResponse(
val key: String,
val version: Int,
val dataSchema: Map<String, Any>,
val uiSchema: Map<String, Any>,
)
data class FormDefinitionResponse(val key: String, val version: Int, val dataSchema: Map<String, Any>, val uiSchema: Map<String, Any>)
@@ -17,6 +17,7 @@ enum class ToolPermission {
ORGANIZATION_MANAGE_TENANT,
WORKFLOW_READ_TENANT,
OPERATIONS_METRICS_READ_TENANT,
PROCESS_CONFIGURATION_MANAGE_TENANT,
}
enum class DataScope { OWN, ASSIGNED, TENANT }
@@ -50,6 +51,7 @@ object AuthorizationPolicy {
ToolPermission.ORGANIZATION_MANAGE_TENANT,
ToolPermission.WORKFLOW_READ_TENANT,
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT,
))
}
return UserCapabilities(
@@ -15,6 +15,18 @@ interface LeaveWorkflowGateway {
leaveType: String,
): StartedProcess
fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApproval(tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
fun resolveTask(taskId: String): WorkflowTask?
@@ -0,0 +1,20 @@
package com.all8ai.aioa.workflow.domain
import java.util.UUID
data class ProcessBinding(
val id: UUID,
val businessType: String,
val formKey: String,
val formVersion: Int,
val processDefinitionKey: String,
val leaveType: String?,
val minDurationMinutes: Long?,
val maxDurationMinutes: Long?,
val priority: Int,
val status: String,
)
fun interface ProcessBindingRouter {
fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding?
}
@@ -27,9 +27,21 @@ class FlowableLeaveWorkflowGateway(
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApprovalWithDefinition(PROCESS_DEFINITION_KEY, tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
override fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess {
val process = runtimeService.createProcessInstanceBuilder()
.processDefinitionKey(PROCESS_DEFINITION_KEY)
.processDefinitionKey(processDefinitionKey)
.businessKey(leaveRequestId.toString())
.variables(
mapOf(
@@ -0,0 +1,31 @@
package com.all8ai.aioa.workflow.infrastructure
import com.all8ai.aioa.workflow.domain.ProcessBinding
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
import org.jooq.DSLContext
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
class JooqProcessBindingRouter(private val dsl: DSLContext) : ProcessBindingRouter {
override fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding? =
dsl.fetchOne(
"""
SELECT * FROM workflow.process_binding
WHERE tenant_id=? AND business_type=? AND status='ACTIVE'
AND (leave_type IS NULL OR leave_type=?)
AND (min_duration_minutes IS NULL OR min_duration_minutes<=?)
AND (max_duration_minutes IS NULL OR max_duration_minutes>=?)
ORDER BY priority DESC,
(CASE WHEN leave_type IS NULL THEN 0 ELSE 1 END) DESC,
(CASE WHEN min_duration_minutes IS NULL AND max_duration_minutes IS NULL THEN 0 ELSE 1 END) DESC
LIMIT 1
""".trimIndent(), tenantId, businessType, leaveType, durationMinutes, durationMinutes,
)?.let { ProcessBinding(
it.get("id", UUID::class.java)!!, it.get("business_type", String::class.java)!!,
it.get("form_key", String::class.java)!!, it.get("form_version", Int::class.java)!!,
it.get("process_definition_key", String::class.java)!!, it.get("leave_type", String::class.java),
it.get("min_duration_minutes", Long::class.java), it.get("max_duration_minutes", Long::class.java),
it.get("priority", Int::class.java)!!, it.get("status", String::class.java)!!,
) }
}
@@ -0,0 +1,58 @@
CREATE TABLE form.definition (
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
form_key VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
status VARCHAR(32) NOT NULL,
data_schema JSONB NOT NULL,
ui_schema JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, form_key, version),
CONSTRAINT ck_form_definition_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'RETIRED'))
);
CREATE UNIQUE INDEX uq_form_definition_published
ON form.definition (tenant_id, form_key)
WHERE status = 'PUBLISHED';
CREATE TABLE workflow.process_binding (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
business_type VARCHAR(100) NOT NULL,
form_key VARCHAR(100) NOT NULL,
form_version INTEGER NOT NULL,
process_definition_key VARCHAR(100) NOT NULL,
leave_type VARCHAR(32),
min_duration_minutes BIGINT,
max_duration_minutes BIGINT,
priority INTEGER NOT NULL DEFAULT 100,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_process_binding_form FOREIGN KEY (tenant_id, form_key, form_version)
REFERENCES form.definition(tenant_id, form_key, version),
CONSTRAINT ck_process_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT ck_process_binding_duration CHECK (
(min_duration_minutes IS NULL OR min_duration_minutes >= 0) AND
(max_duration_minutes IS NULL OR max_duration_minutes >= min_duration_minutes)
)
);
CREATE INDEX idx_process_binding_route
ON workflow.process_binding (tenant_id, business_type, status, priority DESC);
INSERT INTO form.definition (tenant_id, form_key, version, status, data_schema, ui_schema, published_at)
VALUES (
'00000000-0000-7000-8000-000000000001', 'leave-request', 1, 'PUBLISHED',
'{"$id":"leave-request-v1","title":"请假申请","type":"object","required":["type","startsAt","endsAt","reason"],"properties":{"type":{"type":"string","enum":["PERSONAL","SICK","ANNUAL"]},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"reason":{"type":"string","minLength":1,"maxLength":2000}}}'::jsonb,
'{"description":"表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。","sections":[{"title":"请假信息","controls":[{"field":"type","label":"请假类型","control":"select","optionLabels":{"PERSONAL":"事假","SICK":"病假","ANNUAL":"年假"}},{"field":"startsAt","label":"开始时间","control":"dateTime"},{"field":"endsAt","label":"结束时间","control":"dateTime"}]},{"title":"补充说明","controls":[{"field":"reason","label":"请假原因","control":"textArea","placeholder":"请简要说明请假原因","helperText":"AI 可以帮助整理表达,但提交前必须由你确认。"}]}]}'::jsonb,
CURRENT_TIMESTAMP
);
INSERT INTO workflow.process_binding (
id, tenant_id, business_type, form_key, form_version, process_definition_key, priority
) VALUES (
'70000000-0000-7000-8000-000000000001',
'00000000-0000-7000-8000-000000000001',
'LEAVE_REQUEST', 'leave-request', 1, 'leaveApproval', 100
);